Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
023e050d82
commit
cf05fd7f39
45 changed files with 1168 additions and 130 deletions
|
@ -1 +1 @@
|
|||
9523fe6434ea464a6a16c895222a4b001a5c0bca
|
||||
1481a9195c200e375a177cf201058b88bebe271b
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import { mapActions, mapGetters, mapState } from 'vuex';
|
||||
import { mapActions, mapState } from 'vuex';
|
||||
import BoardCardInner from './board_card_inner.vue';
|
||||
|
||||
export default {
|
||||
|
@ -31,7 +31,6 @@ export default {
|
|||
},
|
||||
computed: {
|
||||
...mapState(['selectedBoardItems', 'activeId']),
|
||||
...mapGetters(['isSwimlanesOn']),
|
||||
isActive() {
|
||||
return this.item.id === this.activeId;
|
||||
},
|
||||
|
|
154
app/assets/javascripts/lib/utils/recurrence.js
Normal file
154
app/assets/javascripts/lib/utils/recurrence.js
Normal file
|
@ -0,0 +1,154 @@
|
|||
import { uuids } from '../../diffs/utils/uuids';
|
||||
|
||||
/**
|
||||
* @module recurrence
|
||||
*/
|
||||
|
||||
const instances = {};
|
||||
|
||||
/**
|
||||
* Create a new unique {@link module:recurrence~RecurInstance|RecurInstance}
|
||||
* @returns {module:recurrence.RecurInstance} The newly created {@link module:recurrence~RecurInstance|RecurInstance}
|
||||
*/
|
||||
export function create() {
|
||||
const id = uuids()[0];
|
||||
let handlers = {};
|
||||
let count = 0;
|
||||
|
||||
/**
|
||||
* @namespace RecurInstance
|
||||
* @description A RecurInstance tracks the count of any occurrence as registered by calls to <code>occur</code>.
|
||||
* <br /><br />
|
||||
* It maintains an internal counter and a registry of handlers that can be arbitrarily assigned by a user.
|
||||
* <br /><br />
|
||||
* While a RecurInstance isn't specific to any particular use-case, it may be useful for:
|
||||
* <br />
|
||||
* <ul>
|
||||
* <li>Tracking repeated errors across multiple - but not linked - network requests</li>
|
||||
* <li>Tracking repeated user interactions (e.g. multiple clicks)</li>
|
||||
* </ul>
|
||||
* @summary A closure to track repeated occurrences of any arbitrary event.
|
||||
* */
|
||||
const instance = {
|
||||
/**
|
||||
* @type {module:uuids~UUIDv4}
|
||||
* @description A randomly generated {@link module:uuids~UUIDv4|UUID} for this particular recurrence instance
|
||||
* @memberof module:recurrence~RecurInstance
|
||||
* @readonly
|
||||
* @inner
|
||||
*/
|
||||
get id() {
|
||||
return id;
|
||||
},
|
||||
/**
|
||||
* @type {Number}
|
||||
* @description The number of times this particular instance of recurrence has been triggered
|
||||
* @memberof module:recurrence~RecurInstance
|
||||
* @readonly
|
||||
* @inner
|
||||
*/
|
||||
get count() {
|
||||
return count;
|
||||
},
|
||||
/**
|
||||
* @type {Object}
|
||||
* @description The handlers assigned to this recurrence tracker
|
||||
* @example
|
||||
* myRecurrence.handle( 4, () => console.log( "four" ) );
|
||||
* console.log( myRecurrence.handlers ); // {"4": () => console.log( "four" )}
|
||||
* @memberof module:recurrence~RecurInstance
|
||||
* @readonly
|
||||
* @inner
|
||||
*/
|
||||
get handlers() {
|
||||
return handlers;
|
||||
},
|
||||
/**
|
||||
* @type {Boolean}
|
||||
* @description Delete any internal reference to the instance.
|
||||
* <br />
|
||||
* Keep in mind that this will only attempt to remove the <strong>internal</strong> reference.
|
||||
* <br />
|
||||
* If your code maintains a reference to the instance, the regular garbage collector will not free the memory.
|
||||
* @memberof module:recurrence~RecurInstance
|
||||
* @inner
|
||||
*/
|
||||
free() {
|
||||
return delete instances[id];
|
||||
},
|
||||
/**
|
||||
* @description Register a handler to be called when this occurrence is seen <code>onCount</code> number of times.
|
||||
* @param {Number} onCount - The number of times the occurrence has been seen to respond to
|
||||
* @param {Function} behavior - A callback function to run when the occurrence has been seen <code>onCount</code> times
|
||||
* @memberof module:recurrence~RecurInstance
|
||||
* @inner
|
||||
*/
|
||||
handle(onCount, behavior) {
|
||||
if (onCount && behavior) {
|
||||
handlers[onCount] = behavior;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @description Remove the behavior callback handler that would be run when the occurrence is seen <code>onCount</code> times
|
||||
* @param {Number} onCount - The count identifier for which to eject the callback handler
|
||||
* @memberof module:recurrence~RecurInstance
|
||||
* @inner
|
||||
*/
|
||||
eject(onCount) {
|
||||
if (onCount) {
|
||||
delete handlers[onCount];
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @description Register that this occurrence has been seen and trigger any appropriate handlers
|
||||
* @memberof module:recurrence~RecurInstance
|
||||
* @inner
|
||||
*/
|
||||
occur() {
|
||||
count += 1;
|
||||
|
||||
if (typeof handlers[count] === 'function') {
|
||||
handlers[count](count);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @description Reset this recurrence instance without destroying it entirely
|
||||
* @param {Object} [options]
|
||||
* @param {Boolean} [options.currentCount = true] - Whether to reset the count
|
||||
* @param {Boolean} [options.handlersList = false] - Whether to reset the list of attached handlers back to an empty state
|
||||
* @memberof module:recurrence~RecurInstance
|
||||
* @inner
|
||||
*/
|
||||
reset({ currentCount = true, handlersList = false } = {}) {
|
||||
if (currentCount) {
|
||||
count = 0;
|
||||
}
|
||||
|
||||
if (handlersList) {
|
||||
handlers = {};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
instances[id] = instance;
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a stored {@link module:recurrence~RecurInstance|RecurInstance} by {@link module:uuids~UUIDv4|UUID}
|
||||
* @param {module:uuids~UUIDv4} id - The {@link module:uuids~UUIDv4|UUID} of a previously created {@link module:recurrence~RecurInstance|RecurInstance}
|
||||
* @returns {(module:recurrence~RecurInstance|undefined)} The {@link module:recurrence~RecurInstance|RecurInstance}, or undefined if the UUID doesn't refer to a known Instance
|
||||
*/
|
||||
export function recall(id) {
|
||||
return instances[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the memory space for a given {@link module:recurrence~RecurInstance|RecurInstance} by {@link module:uuids~UUIDv4|UUID}
|
||||
* @param {module:uuids~UUIDv4} id - The {@link module:uuids~UUIDv4|UUID} of a previously created {@link module:recurrence~RecurInstance|RecurInstance}
|
||||
* @returns {Boolean} Whether the reference to the stored {@link module:recurrence~RecurInstance|RecurInstance} was released
|
||||
*/
|
||||
export function free(id) {
|
||||
return recall(id)?.free() || false;
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
<script>
|
||||
import { GlButton, GlIcon } from '@gitlab/ui';
|
||||
import { __ } from '~/locale';
|
||||
|
||||
export default {
|
||||
width: {
|
||||
expanded: '482px',
|
||||
collapsed: '58px',
|
||||
},
|
||||
i18n: {
|
||||
toggleTxt: __('Collapse'),
|
||||
},
|
||||
components: {
|
||||
GlButton,
|
||||
GlIcon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isExpanded: false,
|
||||
topPosition: 0,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
buttonIconName() {
|
||||
return this.isExpanded ? 'chevron-double-lg-right' : 'chevron-double-lg-left';
|
||||
},
|
||||
buttonClass() {
|
||||
return this.isExpanded ? 'gl-justify-content-end!' : '';
|
||||
},
|
||||
rootStyle() {
|
||||
const { expanded, collapsed } = this.$options.width;
|
||||
const top = this.topPosition;
|
||||
const style = { top: `${top}px` };
|
||||
|
||||
return this.isExpanded ? { ...style, width: expanded } : { ...style, width: collapsed };
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setTopPosition();
|
||||
},
|
||||
methods: {
|
||||
setTopPosition() {
|
||||
const navbarEl = document.querySelector('.js-navbar');
|
||||
|
||||
if (navbarEl) {
|
||||
this.topPosition = navbarEl.getBoundingClientRect().bottom;
|
||||
}
|
||||
},
|
||||
toggleDrawer() {
|
||||
this.isExpanded = !this.isExpanded;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<aside
|
||||
aria-live="polite"
|
||||
class="gl-fixed gl-right-0 gl-h-full gl-bg-gray-10 gl-transition-medium gl-border-l-solid gl-border-1 gl-border-gray-100"
|
||||
:style="rootStyle"
|
||||
>
|
||||
<gl-button
|
||||
category="tertiary"
|
||||
class="gl-w-full gl-h-9 gl-rounded-0! gl-border-none! gl-border-b-solid! gl-border-1! gl-border-gray-100 gl-text-decoration-none! gl-outline-0! gl-display-flex"
|
||||
:class="buttonClass"
|
||||
:title="__('Toggle sidebar')"
|
||||
data-testid="toggleBtn"
|
||||
@click="toggleDrawer"
|
||||
>
|
||||
<span v-if="isExpanded" class="gl-text-gray-500 gl-mr-3" data-testid="collapse-text">{{
|
||||
__('Collapse')
|
||||
}}</span>
|
||||
<gl-icon data-testid="toggle-icon" :name="buttonIconName" />
|
||||
</gl-button>
|
||||
<div v-if="isExpanded" class="gl-p-5" data-testid="drawer-content"></div>
|
||||
</aside>
|
||||
</template>
|
|
@ -1,5 +1,7 @@
|
|||
<script>
|
||||
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
|
||||
import CommitSection from './components/commit/commit_section.vue';
|
||||
import PipelineEditorDrawer from './components/drawer/pipeline_editor_drawer.vue';
|
||||
import PipelineEditorFileNav from './components/file_nav/pipeline_editor_file_nav.vue';
|
||||
import PipelineEditorHeader from './components/header/pipeline_editor_header.vue';
|
||||
import PipelineEditorTabs from './components/pipeline_editor_tabs.vue';
|
||||
|
@ -8,10 +10,12 @@ import { TABS_WITH_COMMIT_FORM, CREATE_TAB } from './constants';
|
|||
export default {
|
||||
components: {
|
||||
CommitSection,
|
||||
PipelineEditorDrawer,
|
||||
PipelineEditorFileNav,
|
||||
PipelineEditorHeader,
|
||||
PipelineEditorTabs,
|
||||
},
|
||||
mixins: [glFeatureFlagMixin()],
|
||||
props: {
|
||||
ciConfigData: {
|
||||
type: Object,
|
||||
|
@ -35,6 +39,9 @@ export default {
|
|||
showCommitForm() {
|
||||
return TABS_WITH_COMMIT_FORM.includes(this.currentTab);
|
||||
},
|
||||
showPipelineDrawer() {
|
||||
return this.glFeatures.pipelineEditorDrawer;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
setCurrentTab(tabName) {
|
||||
|
@ -45,7 +52,7 @@ export default {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="gl-pr-9 gl-transition-medium gl-w-full">
|
||||
<pipeline-editor-file-nav v-on="$listeners" />
|
||||
<pipeline-editor-header
|
||||
:ci-config-data="ciConfigData"
|
||||
|
@ -58,5 +65,6 @@ export default {
|
|||
@set-current-tab="setCurrentTab"
|
||||
/>
|
||||
<commit-section v-if="showCommitForm" :ci-file-content="ciFileContent" v-on="$listeners" />
|
||||
<pipeline-editor-drawer v-if="showPipelineDrawer" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -6,6 +6,7 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController
|
|||
push_frontend_feature_flag(:ci_config_merged_tab, @project, default_enabled: :yaml)
|
||||
push_frontend_feature_flag(:pipeline_editor_empty_state_action, @project, default_enabled: :yaml)
|
||||
push_frontend_feature_flag(:pipeline_editor_branch_switcher, @project, default_enabled: :yaml)
|
||||
push_frontend_feature_flag(:pipeline_editor_drawer, @project, default_enabled: :yaml)
|
||||
end
|
||||
|
||||
feature_category :pipeline_authoring
|
||||
|
|
|
@ -227,7 +227,7 @@ class Projects::PipelinesController < Projects::ApplicationController
|
|||
end
|
||||
|
||||
def render_show
|
||||
@stages = @pipeline.stages.with_latest_and_retried_statuses
|
||||
@stages = @pipeline.stages
|
||||
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
|
|
18
app/graphql/types/metadata/kas_type.rb
Normal file
18
app/graphql/types/metadata/kas_type.rb
Normal file
|
@ -0,0 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Types
|
||||
module Metadata
|
||||
class KasType < ::Types::BaseObject
|
||||
graphql_name 'Kas'
|
||||
|
||||
authorize :read_instance_metadata
|
||||
|
||||
field :enabled, GraphQL::BOOLEAN_TYPE, null: false,
|
||||
description: 'Indicates whether the Kubernetes Agent Server is enabled.'
|
||||
field :version, GraphQL::STRING_TYPE, null: true,
|
||||
description: 'KAS version.'
|
||||
field :external_url, GraphQL::STRING_TYPE, null: true,
|
||||
description: 'The URL used by the Agents to communicate with KAS.'
|
||||
end
|
||||
end
|
||||
end
|
|
@ -10,5 +10,7 @@ module Types
|
|||
description: 'Version.'
|
||||
field :revision, GraphQL::STRING_TYPE, null: false,
|
||||
description: 'Revision.'
|
||||
field :kas, ::Types::Metadata::KasType, null: false,
|
||||
description: 'Metadata about KAS.'
|
||||
end
|
||||
end
|
||||
|
|
|
@ -38,6 +38,14 @@ module GroupsHelper
|
|||
]
|
||||
end
|
||||
|
||||
def group_information_title(group)
|
||||
if Feature.enabled?(:sidebar_refactor, current_user)
|
||||
group.subgroup? ? _('Subgroup information') : _('Group information')
|
||||
else
|
||||
group.subgroup? ? _('Subgroup overview') : _('Group overview')
|
||||
end
|
||||
end
|
||||
|
||||
def group_container_registry_nav?
|
||||
Gitlab.config.registry.enabled &&
|
||||
can?(current_user, :read_container_image, @group)
|
||||
|
|
|
@ -6,6 +6,7 @@ module Ci
|
|||
include Importable
|
||||
include Ci::HasStatus
|
||||
include Gitlab::OptimisticLocking
|
||||
include Presentable
|
||||
|
||||
enum status: Ci::HasStatus::STATUSES_ENUM
|
||||
|
||||
|
@ -22,12 +23,6 @@ module Ci
|
|||
scope :ordered, -> { order(position: :asc) }
|
||||
scope :in_pipelines, ->(pipelines) { where(pipeline: pipelines) }
|
||||
scope :by_name, ->(names) { where(name: names) }
|
||||
scope :with_latest_and_retried_statuses, -> do
|
||||
includes(
|
||||
latest_statuses: [:pipeline, project: :namespace],
|
||||
retried_statuses: [:pipeline, project: :namespace]
|
||||
)
|
||||
end
|
||||
|
||||
with_options unless: :importing? do
|
||||
validates :project, presence: true
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class InstanceMetadata
|
||||
attr_reader :version, :revision
|
||||
attr_reader :version, :revision, :kas
|
||||
|
||||
def initialize(version: Gitlab::VERSION, revision: Gitlab.revision)
|
||||
@version = version
|
||||
@revision = revision
|
||||
@kas = ::InstanceMetadata::Kas.new
|
||||
end
|
||||
end
|
||||
|
|
15
app/models/instance_metadata/kas.rb
Normal file
15
app/models/instance_metadata/kas.rb
Normal file
|
@ -0,0 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class InstanceMetadata::Kas
|
||||
attr_reader :enabled, :version, :external_url
|
||||
|
||||
def initialize
|
||||
@enabled = Gitlab::Kas.enabled?
|
||||
@version = Gitlab::Kas.version if @enabled
|
||||
@external_url = Gitlab::Kas.external_url if @enabled
|
||||
end
|
||||
|
||||
def self.declarative_policy_class
|
||||
"InstanceMetadataPolicy"
|
||||
end
|
||||
end
|
32
app/presenters/ci/stage_presenter.rb
Normal file
32
app/presenters/ci/stage_presenter.rb
Normal file
|
@ -0,0 +1,32 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Ci
|
||||
class StagePresenter < Gitlab::View::Presenter::Delegated
|
||||
presents :stage
|
||||
|
||||
def latest_ordered_statuses
|
||||
preload_statuses(stage.statuses.latest_ordered)
|
||||
end
|
||||
|
||||
def retried_ordered_statuses
|
||||
preload_statuses(stage.statuses.retried_ordered)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def preload_statuses(statuses)
|
||||
loaded_statuses = statuses.load
|
||||
statuses.tap do |statuses|
|
||||
# rubocop: disable CodeReuse/ActiveRecord
|
||||
ActiveRecord::Associations::Preloader.new.preload(preloadable_statuses(loaded_statuses), %w[pipeline tags job_artifacts_archive metadata])
|
||||
# rubocop: enable CodeReuse/ActiveRecord
|
||||
end
|
||||
end
|
||||
|
||||
def preloadable_statuses(statuses)
|
||||
statuses.reject do |status|
|
||||
status.instance_of?(::GenericCommitStatus) || status.instance_of?(::Ci::Bridge)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,7 +1,6 @@
|
|||
- issues_count = cached_issuables_count(@group, type: :issues)
|
||||
- merge_requests_count = group_open_merge_requests_count(@group)
|
||||
- aside_title = @group.subgroup? ? _('Subgroup navigation') : _('Group navigation')
|
||||
- overview_title = @group.subgroup? ? _('Subgroup overview') : _('Group overview')
|
||||
|
||||
%aside.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), **sidebar_tracking_attributes_by_object(@group), 'aria-label': aside_title }
|
||||
.nav-sidebar-inner-scroll
|
||||
|
@ -19,21 +18,23 @@
|
|||
= nav_link(path: paths, unless: -> { current_path?('groups/contribution_analytics#show') }, html_options: { class: 'home' }) do
|
||||
= link_to group_path(@group) do
|
||||
.nav-icon-container
|
||||
= sprite_icon('home')
|
||||
- sprite = Feature.enabled?(:sidebar_refactor, current_user) ? 'group' : 'home'
|
||||
= sprite_icon(sprite)
|
||||
%span.nav-item-name
|
||||
= overview_title
|
||||
= group_information_title(@group)
|
||||
|
||||
%ul.sidebar-sub-level-items
|
||||
= nav_link(path: ['groups#show', 'groups#details', 'groups#activity', 'groups#subgroups'], html_options: { class: "fly-out-top-item" } ) do
|
||||
= link_to group_path(@group) do
|
||||
%strong.fly-out-top-item-name
|
||||
= overview_title
|
||||
= group_information_title(@group)
|
||||
%li.divider.fly-out-top-item
|
||||
|
||||
= nav_link(path: ['groups#show', 'groups#details', 'groups#subgroups'], html_options: { class: 'home' }) do
|
||||
= link_to details_group_path(@group), title: _('Group details') do
|
||||
%span
|
||||
= _('Details')
|
||||
- if Feature.disabled?(:sidebar_refactor, current_user)
|
||||
= nav_link(path: ['groups#show', 'groups#details', 'groups#subgroups'], html_options: { class: 'home' }) do
|
||||
= link_to details_group_path(@group), title: _('Group details') do
|
||||
%span
|
||||
= _('Details')
|
||||
|
||||
- if group_sidebar_link?(:activity)
|
||||
= nav_link(path: 'groups#activity') do
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
- stage = stage.present(current_user: current_user)
|
||||
|
||||
%tr
|
||||
%th{ colspan: 10 }
|
||||
%strong
|
||||
|
@ -6,8 +8,8 @@
|
|||
= ci_icon_for_status(stage.status)
|
||||
|
||||
= stage.name.titleize
|
||||
= render stage.latest_statuses, stage: false, ref: false, pipeline_link: false, allow_retry: true
|
||||
= render stage.retried_statuses, stage: false, ref: false, pipeline_link: false, retried: true
|
||||
= render stage.latest_ordered_statuses, stage: false, ref: false, pipeline_link: false, allow_retry: true
|
||||
= render stage.retried_ordered_statuses, stage: false, ref: false, pipeline_link: false, retried: true
|
||||
%tr
|
||||
%td{ colspan: 10 }
|
||||
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Expose KAS metadata through GraphQL - enabled, version and externalUrl
|
||||
merge_request: 59696
|
||||
author:
|
||||
type: added
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Fix N+1 SQL queries in PipelinesController#show
|
||||
merge_request: 60794
|
||||
author:
|
||||
type: fixed
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
name: pipeline_editor_drawer
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/60856
|
||||
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/329806
|
||||
milestone: '13.12'
|
||||
type: development
|
||||
group: group::pipeline authoring
|
||||
default_enabled: false
|
|
@ -9511,6 +9511,16 @@ four standard [pagination arguments](#connection-pagination-arguments):
|
|||
| <a id="jobpermissionsreadjobartifacts"></a>`readJobArtifacts` | [`Boolean!`](#boolean) | Indicates the user can perform `read_job_artifacts` on this resource. |
|
||||
| <a id="jobpermissionsupdatebuild"></a>`updateBuild` | [`Boolean!`](#boolean) | Indicates the user can perform `update_build` on this resource. |
|
||||
|
||||
### `Kas`
|
||||
|
||||
#### Fields
|
||||
|
||||
| Name | Type | Description |
|
||||
| ---- | ---- | ----------- |
|
||||
| <a id="kasenabled"></a>`enabled` | [`Boolean!`](#boolean) | Indicates whether the Kubernetes Agent Server is enabled. |
|
||||
| <a id="kasexternalurl"></a>`externalUrl` | [`String`](#string) | The URL used by the Agents to communicate with KAS. |
|
||||
| <a id="kasversion"></a>`version` | [`String`](#string) | KAS version. |
|
||||
|
||||
### `Label`
|
||||
|
||||
#### Fields
|
||||
|
@ -10110,6 +10120,7 @@ four standard [pagination arguments](#connection-pagination-arguments):
|
|||
|
||||
| Name | Type | Description |
|
||||
| ---- | ---- | ----------- |
|
||||
| <a id="metadatakas"></a>`kas` | [`Kas!`](#kas) | Metadata about KAS. |
|
||||
| <a id="metadatarevision"></a>`revision` | [`String!`](#string) | Revision. |
|
||||
| <a id="metadataversion"></a>`version` | [`String!`](#string) | Version. |
|
||||
|
||||
|
|
|
@ -126,6 +126,18 @@ Examples:
|
|||
- `index_projects_on_id_service_desk_enabled`
|
||||
- `index_clusters_on_enabled_cluster_type_id_and_created_at`
|
||||
|
||||
### Truncate long index names
|
||||
|
||||
PostgreSQL [limits the length of identifiers](https://www.postgresql.org/docs/current/limits.html),
|
||||
like column or index names. Column names are not usually a problem, but index names tend
|
||||
to be longer. Some methods for shortening a name that's too long:
|
||||
|
||||
- Prefix it with `i_` instead of `index_`.
|
||||
- Skip redundant prefixes. For example,
|
||||
`index_vulnerability_findings_remediations_on_vulnerability_remediation_id` becomes
|
||||
`index_vulnerability_findings_remediations_on_remediation_id`.
|
||||
- Instead of columns, specify the purpose of the index, such as `index_users_for_unconfirmation_notification`.
|
||||
|
||||
## Heavy operations in a single transaction
|
||||
|
||||
When using a single-transaction migration, a transaction holds a database connection
|
||||
|
|
|
@ -4,6 +4,9 @@ module Gitlab
|
|||
module Database
|
||||
module Migrations
|
||||
class Instrumentation
|
||||
RESULT_DIR = Rails.root.join('tmp', 'migration-testing').freeze
|
||||
STATS_FILENAME = 'migration-stats.json'
|
||||
|
||||
attr_reader :observations
|
||||
|
||||
def initialize(observers = ::Gitlab::Database::Migrations::Observers.all_observers)
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
module Gitlab
|
||||
module Kas
|
||||
INTERNAL_API_REQUEST_HEADER = 'Gitlab-Kas-Api-Request'
|
||||
VERSION_FILE = 'GITLAB_KAS_VERSION'
|
||||
JWT_ISSUER = 'gitlab-kas'
|
||||
|
||||
include JwtAuthenticatable
|
||||
|
@ -29,6 +30,27 @@ module Gitlab
|
|||
|
||||
Feature.enabled?(:kubernetes_agent_on_gitlab_com, project, default_enabled: :yaml)
|
||||
end
|
||||
|
||||
# Return GitLab KAS version
|
||||
#
|
||||
# @return [String] version
|
||||
def version
|
||||
@_version ||= Rails.root.join(VERSION_FILE).read.chomp
|
||||
end
|
||||
|
||||
# Return GitLab KAS external_url
|
||||
#
|
||||
# @return [String] external_url
|
||||
def external_url
|
||||
Gitlab.config.gitlab_kas.external_url
|
||||
end
|
||||
|
||||
# Return whether GitLab KAS is enabled
|
||||
#
|
||||
# @return [Boolean] external_url
|
||||
def enabled?
|
||||
!!Gitlab.config['gitlab_kas']&.fetch('enabled', false)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -217,9 +217,11 @@ namespace :gitlab do
|
|||
end
|
||||
|
||||
desc 'Run migrations with instrumentation'
|
||||
task :migration_testing, [:result_file] => :environment do |_, args|
|
||||
result_file = args[:result_file] || raise("Please specify result_file argument")
|
||||
raise "File exists already, won't overwrite: #{result_file}" if File.exist?(result_file)
|
||||
task migration_testing: :environment do
|
||||
result_dir = Gitlab::Database::Migrations::Instrumentation::RESULT_DIR
|
||||
raise "Directory exists already, won't overwrite: #{result_dir}" if File.exist?(result_dir)
|
||||
|
||||
Dir.mkdir(result_dir)
|
||||
|
||||
verbose_was = ActiveRecord::Migration.verbose
|
||||
ActiveRecord::Migration.verbose = true
|
||||
|
@ -240,7 +242,7 @@ namespace :gitlab do
|
|||
end
|
||||
ensure
|
||||
if instrumentation
|
||||
File.open(result_file, 'wb+') do |io|
|
||||
File.open(File.join(result_dir, Gitlab::Database::Migrations::Instrumentation::STATS_FILENAME), 'wb+') do |io|
|
||||
io << instrumentation.observations.to_json
|
||||
end
|
||||
end
|
||||
|
|
|
@ -15477,6 +15477,9 @@ msgstr ""
|
|||
msgid "Group info:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Group information"
|
||||
msgstr ""
|
||||
|
||||
msgid "Group is required when cluster_type is :group"
|
||||
msgstr ""
|
||||
|
||||
|
@ -30725,6 +30728,9 @@ msgstr ""
|
|||
msgid "StorageSize|Unknown"
|
||||
msgstr ""
|
||||
|
||||
msgid "Subgroup information"
|
||||
msgstr ""
|
||||
|
||||
msgid "Subgroup milestone"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
@ -53,7 +53,7 @@
|
|||
"@gitlab/favicon-overlay": "2.0.0",
|
||||
"@gitlab/svgs": "1.192.0",
|
||||
"@gitlab/tributejs": "1.0.0",
|
||||
"@gitlab/ui": "29.13.0",
|
||||
"@gitlab/ui": "29.14.0",
|
||||
"@gitlab/visual-review-tools": "1.6.1",
|
||||
"@rails/actioncable": "^6.0.3-4",
|
||||
"@rails/ujs": "^6.0.3-4",
|
||||
|
|
|
@ -290,6 +290,39 @@ RSpec.describe Projects::PipelinesController do
|
|||
end
|
||||
end
|
||||
|
||||
describe 'GET #show' do
|
||||
render_views
|
||||
|
||||
let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
|
||||
|
||||
subject { get_pipeline_html }
|
||||
|
||||
def get_pipeline_html
|
||||
get :show, params: { namespace_id: project.namespace, project_id: project, id: pipeline }, format: :html
|
||||
end
|
||||
|
||||
def create_build_with_artifacts(stage, stage_idx, name)
|
||||
create(:ci_build, :artifacts, :tags, pipeline: pipeline, stage: stage, stage_idx: stage_idx, name: name)
|
||||
end
|
||||
|
||||
before do
|
||||
create_build_with_artifacts('build', 0, 'job1')
|
||||
create_build_with_artifacts('build', 0, 'job2')
|
||||
end
|
||||
|
||||
it 'avoids N+1 database queries', :request_store do
|
||||
get_pipeline_html
|
||||
|
||||
control_count = ActiveRecord::QueryRecorder.new { get_pipeline_html }.count
|
||||
expect(response).to have_gitlab_http_status(:ok)
|
||||
|
||||
create_build_with_artifacts('build', 0, 'job3')
|
||||
|
||||
expect { get_pipeline_html }.not_to exceed_query_limit(control_count)
|
||||
expect(response).to have_gitlab_http_status(:ok)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET show.json' do
|
||||
let(:pipeline) { create(:ci_pipeline, project: project) }
|
||||
|
||||
|
|
|
@ -15,8 +15,7 @@ RSpec.describe 'The group page' do
|
|||
|
||||
def expect_all_sidebar_links
|
||||
within('.nav-sidebar') do
|
||||
expect(page).to have_link('Group overview')
|
||||
expect(page).to have_link('Details')
|
||||
expect(page).to have_link('Group information')
|
||||
expect(page).to have_link('Activity')
|
||||
expect(page).to have_link('Issues')
|
||||
expect(page).to have_link('Merge requests')
|
||||
|
@ -44,8 +43,7 @@ RSpec.describe 'The group page' do
|
|||
visit group_path(group)
|
||||
|
||||
within('.nav-sidebar') do
|
||||
expect(page).to have_link('Group overview')
|
||||
expect(page).to have_link('Details')
|
||||
expect(page).to have_link('Group information')
|
||||
expect(page).not_to have_link('Activity')
|
||||
expect(page).not_to have_link('Contribution')
|
||||
|
||||
|
|
|
@ -14,9 +14,8 @@ RSpec.describe 'Group navbar' do
|
|||
let(:structure) do
|
||||
[
|
||||
{
|
||||
nav_item: _('Group overview'),
|
||||
nav_item: _('Group information'),
|
||||
nav_sub_items: [
|
||||
_('Details'),
|
||||
_('Activity')
|
||||
]
|
||||
},
|
||||
|
|
|
@ -368,14 +368,14 @@ RSpec.describe 'Group' do
|
|||
|
||||
expect(page).to have_content(nested_group.name)
|
||||
expect(page).to have_content(project.name)
|
||||
expect(page).to have_link('Group overview')
|
||||
expect(page).to have_link('Group information')
|
||||
end
|
||||
|
||||
it 'renders subgroup page with the text "Subgroup overview"' do
|
||||
it 'renders subgroup page with the text "Subgroup information"' do
|
||||
visit group_path(nested_group)
|
||||
wait_for_requests
|
||||
|
||||
expect(page).to have_link('Subgroup overview')
|
||||
expect(page).to have_link('Subgroup information')
|
||||
end
|
||||
|
||||
it 'renders project page with the text "Project overview"' do
|
||||
|
|
|
@ -15,7 +15,7 @@ describe('Board card', () => {
|
|||
const localVue = createLocalVue();
|
||||
localVue.use(Vuex);
|
||||
|
||||
const createStore = ({ initialState = {}, isSwimlanesOn = false } = {}) => {
|
||||
const createStore = ({ initialState = {} } = {}) => {
|
||||
mockActions = {
|
||||
toggleBoardItem: jest.fn(),
|
||||
toggleBoardItemMultiSelection: jest.fn(),
|
||||
|
@ -30,7 +30,6 @@ describe('Board card', () => {
|
|||
},
|
||||
actions: mockActions,
|
||||
getters: {
|
||||
isSwimlanesOn: () => isSwimlanesOn,
|
||||
isEpicBoard: () => false,
|
||||
},
|
||||
});
|
||||
|
@ -90,72 +89,65 @@ describe('Board card', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe.each`
|
||||
isSwimlanesOn
|
||||
${true} | ${false}
|
||||
`('when isSwimlanesOn is $isSwimlanesOn', ({ isSwimlanesOn }) => {
|
||||
it('should not highlight the card by default', async () => {
|
||||
createStore({ isSwimlanesOn });
|
||||
mountComponent();
|
||||
it('should not highlight the card by default', async () => {
|
||||
createStore();
|
||||
mountComponent();
|
||||
|
||||
expect(wrapper.classes()).not.toContain('is-active');
|
||||
expect(wrapper.classes()).not.toContain('multi-select');
|
||||
expect(wrapper.classes()).not.toContain('is-active');
|
||||
expect(wrapper.classes()).not.toContain('multi-select');
|
||||
});
|
||||
|
||||
it('should highlight the card with a correct style when selected', async () => {
|
||||
createStore({
|
||||
initialState: {
|
||||
activeId: mockIssue.id,
|
||||
},
|
||||
});
|
||||
mountComponent();
|
||||
|
||||
expect(wrapper.classes()).toContain('is-active');
|
||||
expect(wrapper.classes()).not.toContain('multi-select');
|
||||
});
|
||||
|
||||
it('should highlight the card with a correct style when multi-selected', async () => {
|
||||
createStore({
|
||||
initialState: {
|
||||
activeId: inactiveId,
|
||||
selectedBoardItems: [mockIssue],
|
||||
},
|
||||
});
|
||||
mountComponent();
|
||||
|
||||
expect(wrapper.classes()).toContain('multi-select');
|
||||
expect(wrapper.classes()).not.toContain('is-active');
|
||||
});
|
||||
|
||||
describe('when mouseup event is called on the card', () => {
|
||||
beforeEach(() => {
|
||||
createStore();
|
||||
mountComponent();
|
||||
});
|
||||
|
||||
it('should highlight the card with a correct style when selected', async () => {
|
||||
createStore({
|
||||
initialState: {
|
||||
activeId: mockIssue.id,
|
||||
},
|
||||
isSwimlanesOn,
|
||||
});
|
||||
mountComponent();
|
||||
describe('when not using multi-select', () => {
|
||||
it('should call vuex action "toggleBoardItem" with correct parameters', async () => {
|
||||
await selectCard();
|
||||
|
||||
expect(wrapper.classes()).toContain('is-active');
|
||||
expect(wrapper.classes()).not.toContain('multi-select');
|
||||
});
|
||||
|
||||
it('should highlight the card with a correct style when multi-selected', async () => {
|
||||
createStore({
|
||||
initialState: {
|
||||
activeId: inactiveId,
|
||||
selectedBoardItems: [mockIssue],
|
||||
},
|
||||
isSwimlanesOn,
|
||||
});
|
||||
mountComponent();
|
||||
|
||||
expect(wrapper.classes()).toContain('multi-select');
|
||||
expect(wrapper.classes()).not.toContain('is-active');
|
||||
});
|
||||
|
||||
describe('when mouseup event is called on the card', () => {
|
||||
beforeEach(() => {
|
||||
createStore({ isSwimlanesOn });
|
||||
mountComponent();
|
||||
});
|
||||
|
||||
describe('when not using multi-select', () => {
|
||||
it('should call vuex action "toggleBoardItem" with correct parameters', async () => {
|
||||
await selectCard();
|
||||
|
||||
expect(mockActions.toggleBoardItem).toHaveBeenCalledTimes(1);
|
||||
expect(mockActions.toggleBoardItem).toHaveBeenCalledWith(expect.any(Object), {
|
||||
boardItem: mockIssue,
|
||||
});
|
||||
expect(mockActions.toggleBoardItem).toHaveBeenCalledTimes(1);
|
||||
expect(mockActions.toggleBoardItem).toHaveBeenCalledWith(expect.any(Object), {
|
||||
boardItem: mockIssue,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when using multi-select', () => {
|
||||
it('should call vuex action "multiSelectBoardItem" with correct parameters', async () => {
|
||||
await multiSelectCard();
|
||||
describe('when using multi-select', () => {
|
||||
it('should call vuex action "multiSelectBoardItem" with correct parameters', async () => {
|
||||
await multiSelectCard();
|
||||
|
||||
expect(mockActions.toggleBoardItemMultiSelection).toHaveBeenCalledTimes(1);
|
||||
expect(mockActions.toggleBoardItemMultiSelection).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
mockIssue,
|
||||
);
|
||||
});
|
||||
expect(mockActions.toggleBoardItemMultiSelection).toHaveBeenCalledTimes(1);
|
||||
expect(mockActions.toggleBoardItemMultiSelection).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
mockIssue,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
333
spec/frontend/lib/utils/recurrence_spec.js
Normal file
333
spec/frontend/lib/utils/recurrence_spec.js
Normal file
|
@ -0,0 +1,333 @@
|
|||
import { create, free, recall } from '~/lib/utils/recurrence';
|
||||
|
||||
const HEX = /[a-f0-9]/i;
|
||||
const HEX_RE = HEX.source;
|
||||
const UUIDV4 = new RegExp(
|
||||
`${HEX_RE}{8}-${HEX_RE}{4}-4${HEX_RE}{3}-[89ab]${HEX_RE}{3}-${HEX_RE}{12}`,
|
||||
'i',
|
||||
);
|
||||
|
||||
describe('recurrence', () => {
|
||||
let recurInstance;
|
||||
let id;
|
||||
|
||||
beforeEach(() => {
|
||||
recurInstance = create();
|
||||
id = recurInstance.id;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
id = null;
|
||||
recurInstance.free();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('returns an object with the correct external api', () => {
|
||||
expect(recurInstance).toMatchObject(
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(UUIDV4),
|
||||
count: 0,
|
||||
handlers: {},
|
||||
free: expect.any(Function),
|
||||
handle: expect.any(Function),
|
||||
eject: expect.any(Function),
|
||||
occur: expect.any(Function),
|
||||
reset: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recall', () => {
|
||||
it('returns a previously created RecurInstance', () => {
|
||||
expect(recall(id).id).toBe(id);
|
||||
});
|
||||
|
||||
it("returns undefined if the provided UUID doesn't refer to a stored RecurInstance", () => {
|
||||
expect(recall('1234')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('free', () => {
|
||||
it('returns true when the RecurInstance exists', () => {
|
||||
expect(free(id)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the ID doesn't refer to a known RecurInstance", () => {
|
||||
expect(free('1234')).toBe(false);
|
||||
});
|
||||
|
||||
it('removes the correct RecurInstance from the list of references', () => {
|
||||
const anotherInstance = create();
|
||||
|
||||
expect(recall(id)).toEqual(recurInstance);
|
||||
expect(recall(anotherInstance.id)).toEqual(anotherInstance);
|
||||
|
||||
free(id);
|
||||
|
||||
expect(recall(id)).toBeUndefined();
|
||||
expect(recall(anotherInstance.id)).toEqual(anotherInstance);
|
||||
|
||||
anotherInstance.free();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RecurInstance (`create()` return value)', () => {
|
||||
it.each`
|
||||
property | value | alias
|
||||
${'id'} | ${expect.stringMatching(UUIDV4)} | ${'[a string matching the UUIDv4 specification]'}
|
||||
${'count'} | ${0} | ${0}
|
||||
${'handlers'} | ${{}} | ${{}}
|
||||
`(
|
||||
'has the correct primitive value $alias for the member `$property` to start',
|
||||
({ property, value }) => {
|
||||
expect(recurInstance[property]).toEqual(value);
|
||||
},
|
||||
);
|
||||
|
||||
describe('id', () => {
|
||||
it('cannot be changed manually', () => {
|
||||
expect(() => {
|
||||
recurInstance.id = 'new-id';
|
||||
}).toThrow(TypeError);
|
||||
|
||||
expect(recurInstance.id).toBe(id);
|
||||
});
|
||||
|
||||
it.each`
|
||||
method
|
||||
${'free'}
|
||||
${'handle'}
|
||||
${'eject'}
|
||||
${'occur'}
|
||||
${'reset'}
|
||||
`('does not change across any method call - like after `$method`', ({ method }) => {
|
||||
recurInstance[method]();
|
||||
|
||||
expect(recurInstance.id).toBe(id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('count', () => {
|
||||
it('cannot be changed manually', () => {
|
||||
expect(() => {
|
||||
recurInstance.count = 9999;
|
||||
}).toThrow(TypeError);
|
||||
|
||||
expect(recurInstance.count).toBe(0);
|
||||
});
|
||||
|
||||
it.each`
|
||||
method
|
||||
${'free'}
|
||||
${'handle'}
|
||||
${'eject'}
|
||||
${'reset'}
|
||||
`("doesn't change in unexpected scenarios - like after a call to `$method`", ({ method }) => {
|
||||
recurInstance[method]();
|
||||
|
||||
expect(recurInstance.count).toBe(0);
|
||||
});
|
||||
|
||||
it('increments by one each time `.occur()` is called', () => {
|
||||
expect(recurInstance.count).toBe(0);
|
||||
recurInstance.occur();
|
||||
expect(recurInstance.count).toBe(1);
|
||||
recurInstance.occur();
|
||||
expect(recurInstance.count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handlers', () => {
|
||||
it('cannot be changed manually', () => {
|
||||
const fn = jest.fn();
|
||||
|
||||
recurInstance.handle(1, fn);
|
||||
expect(() => {
|
||||
recurInstance.handlers = {};
|
||||
}).toThrow(TypeError);
|
||||
|
||||
expect(recurInstance.handlers).toStrictEqual({
|
||||
1: fn,
|
||||
});
|
||||
});
|
||||
|
||||
it.each`
|
||||
method
|
||||
${'free'}
|
||||
${'occur'}
|
||||
${'eject'}
|
||||
${'reset'}
|
||||
`("doesn't change in unexpected scenarios - like after a call to `$method`", ({ method }) => {
|
||||
recurInstance[method]();
|
||||
|
||||
expect(recurInstance.handlers).toEqual({});
|
||||
});
|
||||
|
||||
it('adds handlers to the correct slots', () => {
|
||||
const fn1 = jest.fn();
|
||||
const fn2 = jest.fn();
|
||||
|
||||
recurInstance.handle(100, fn1);
|
||||
recurInstance.handle(1000, fn2);
|
||||
|
||||
expect(recurInstance.handlers).toMatchObject({
|
||||
100: fn1,
|
||||
1000: fn2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('free', () => {
|
||||
it('removes itself from recallable memory', () => {
|
||||
expect(recall(id)).toEqual(recurInstance);
|
||||
|
||||
recurInstance.free();
|
||||
|
||||
expect(recall(id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handle', () => {
|
||||
it('adds a handler for the provided count', () => {
|
||||
const fn = jest.fn();
|
||||
|
||||
recurInstance.handle(5, fn);
|
||||
|
||||
expect(recurInstance.handlers[5]).toEqual(fn);
|
||||
});
|
||||
|
||||
it("doesn't add any handlers if either the count or behavior aren't provided", () => {
|
||||
const fn = jest.fn();
|
||||
|
||||
recurInstance.handle(null, fn);
|
||||
// Note that it's not possible to react to something not happening (without timers)
|
||||
recurInstance.handle(0, fn);
|
||||
recurInstance.handle(5, null);
|
||||
|
||||
expect(recurInstance.handlers).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('eject', () => {
|
||||
it('removes the handler assigned to the particular count slot', () => {
|
||||
recurInstance.handle(1, jest.fn());
|
||||
|
||||
expect(recurInstance.handlers[1]).toBeTruthy();
|
||||
|
||||
recurInstance.eject(1);
|
||||
|
||||
expect(recurInstance.handlers).toEqual({});
|
||||
});
|
||||
|
||||
it("succeeds (or fails gracefully) when the count provided doesn't have a handler assigned", () => {
|
||||
recurInstance.eject('abc');
|
||||
recurInstance.eject(1);
|
||||
|
||||
expect(recurInstance.handlers).toEqual({});
|
||||
});
|
||||
|
||||
it('makes no changes if no count is provided', () => {
|
||||
const fn = jest.fn();
|
||||
|
||||
recurInstance.handle(1, fn);
|
||||
|
||||
recurInstance.eject();
|
||||
|
||||
expect(recurInstance.handlers[1]).toStrictEqual(fn);
|
||||
});
|
||||
});
|
||||
|
||||
describe('occur', () => {
|
||||
it('increments the .count property by 1', () => {
|
||||
expect(recurInstance.count).toBe(0);
|
||||
|
||||
recurInstance.occur();
|
||||
|
||||
expect(recurInstance.count).toBe(1);
|
||||
});
|
||||
|
||||
it('calls the appropriate handlers', () => {
|
||||
const fn1 = jest.fn();
|
||||
const fn5 = jest.fn();
|
||||
const fn10 = jest.fn();
|
||||
|
||||
recurInstance.handle(1, fn1);
|
||||
recurInstance.handle(5, fn5);
|
||||
recurInstance.handle(10, fn10);
|
||||
|
||||
expect(fn1).not.toHaveBeenCalled();
|
||||
expect(fn5).not.toHaveBeenCalled();
|
||||
expect(fn10).not.toHaveBeenCalled();
|
||||
|
||||
recurInstance.occur();
|
||||
|
||||
expect(fn1).toHaveBeenCalledTimes(1);
|
||||
expect(fn5).not.toHaveBeenCalled();
|
||||
expect(fn10).not.toHaveBeenCalled();
|
||||
|
||||
recurInstance.occur();
|
||||
recurInstance.occur();
|
||||
recurInstance.occur();
|
||||
recurInstance.occur();
|
||||
|
||||
expect(fn1).toHaveBeenCalledTimes(1);
|
||||
expect(fn5).toHaveBeenCalledTimes(1);
|
||||
expect(fn10).not.toHaveBeenCalled();
|
||||
|
||||
recurInstance.occur();
|
||||
recurInstance.occur();
|
||||
recurInstance.occur();
|
||||
recurInstance.occur();
|
||||
recurInstance.occur();
|
||||
|
||||
expect(fn1).toHaveBeenCalledTimes(1);
|
||||
expect(fn5).toHaveBeenCalledTimes(1);
|
||||
expect(fn10).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('resets the count only, by default', () => {
|
||||
const fn = jest.fn();
|
||||
|
||||
recurInstance.handle(3, fn);
|
||||
recurInstance.occur();
|
||||
recurInstance.occur();
|
||||
|
||||
expect(recurInstance.count).toBe(2);
|
||||
|
||||
recurInstance.reset();
|
||||
|
||||
expect(recurInstance.count).toBe(0);
|
||||
expect(recurInstance.handlers).toEqual({ 3: fn });
|
||||
});
|
||||
|
||||
it('also resets the handlers, by specific request', () => {
|
||||
const fn = jest.fn();
|
||||
|
||||
recurInstance.handle(3, fn);
|
||||
recurInstance.occur();
|
||||
recurInstance.occur();
|
||||
|
||||
expect(recurInstance.count).toBe(2);
|
||||
|
||||
recurInstance.reset({ handlersList: true });
|
||||
|
||||
expect(recurInstance.count).toBe(0);
|
||||
expect(recurInstance.handlers).toEqual({});
|
||||
});
|
||||
|
||||
it('leaves the count in place, by request', () => {
|
||||
recurInstance.occur();
|
||||
recurInstance.occur();
|
||||
|
||||
expect(recurInstance.count).toBe(2);
|
||||
|
||||
recurInstance.reset({ currentCount: false });
|
||||
|
||||
expect(recurInstance.count).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,74 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import PipelineEditorDrawer from '~/pipeline_editor/components/drawer/pipeline_editor_drawer.vue';
|
||||
|
||||
describe('Pipeline editor drawer', () => {
|
||||
let wrapper;
|
||||
|
||||
const createComponent = () => {
|
||||
wrapper = shallowMount(PipelineEditorDrawer);
|
||||
};
|
||||
|
||||
const findToggleBtn = () => wrapper.find('[data-testid="toggleBtn"]');
|
||||
const findArrowIcon = () => wrapper.find('[data-testid="toggle-icon"]');
|
||||
const findCollapseText = () => wrapper.find('[data-testid="collapse-text"]');
|
||||
const findDrawerContent = () => wrapper.find('[data-testid="drawer-content"]');
|
||||
|
||||
const clickToggleBtn = async () => findToggleBtn().vm.$emit('click');
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
describe('when the drawer is collapsed', () => {
|
||||
beforeEach(() => {
|
||||
createComponent();
|
||||
});
|
||||
|
||||
it('show the left facing arrow icon', () => {
|
||||
expect(findArrowIcon().props('name')).toBe('chevron-double-lg-left');
|
||||
});
|
||||
|
||||
it('does not show the collapse text', () => {
|
||||
expect(findCollapseText().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not show the drawer content', () => {
|
||||
expect(findDrawerContent().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('can open the drawer by clicking on the toggle button', async () => {
|
||||
expect(findDrawerContent().exists()).toBe(false);
|
||||
|
||||
await clickToggleBtn();
|
||||
|
||||
expect(findDrawerContent().exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the drawer is expanded', () => {
|
||||
beforeEach(async () => {
|
||||
createComponent();
|
||||
await clickToggleBtn();
|
||||
});
|
||||
|
||||
it('show the right facing arrow icon', () => {
|
||||
expect(findArrowIcon().props('name')).toBe('chevron-double-lg-right');
|
||||
});
|
||||
|
||||
it('shows the collapse text', () => {
|
||||
expect(findCollapseText().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('show the drawer content', () => {
|
||||
expect(findDrawerContent().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('can close the drawer by clicking on the toggle button', async () => {
|
||||
expect(findDrawerContent().exists()).toBe(true);
|
||||
|
||||
await clickToggleBtn();
|
||||
|
||||
expect(findDrawerContent().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils';
|
|||
import { nextTick } from 'vue';
|
||||
|
||||
import CommitSection from '~/pipeline_editor/components/commit/commit_section.vue';
|
||||
import PipelineEditorDrawer from '~/pipeline_editor/components/drawer/pipeline_editor_drawer.vue';
|
||||
import PipelineEditorFileNav from '~/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue';
|
||||
import PipelineEditorHeader from '~/pipeline_editor/components/header/pipeline_editor_header.vue';
|
||||
import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue';
|
||||
|
@ -13,7 +14,7 @@ import { mockLintResponse, mockCiYml } from './mock_data';
|
|||
describe('Pipeline editor home wrapper', () => {
|
||||
let wrapper;
|
||||
|
||||
const createComponent = ({ props = {} } = {}) => {
|
||||
const createComponent = ({ props = {}, glFeatures = {} } = {}) => {
|
||||
wrapper = shallowMount(PipelineEditorHome, {
|
||||
propsData: {
|
||||
ciConfigData: mockLintResponse,
|
||||
|
@ -22,13 +23,20 @@ describe('Pipeline editor home wrapper', () => {
|
|||
isNewCiConfigFile: false,
|
||||
...props,
|
||||
},
|
||||
provide: {
|
||||
glFeatures: {
|
||||
pipelineEditorDrawer: true,
|
||||
...glFeatures,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const findPipelineEditorHeader = () => wrapper.findComponent(PipelineEditorHeader);
|
||||
const findPipelineEditorTabs = () => wrapper.findComponent(PipelineEditorTabs);
|
||||
const findCommitSection = () => wrapper.findComponent(CommitSection);
|
||||
const findFileNav = () => wrapper.findComponent(PipelineEditorFileNav);
|
||||
const findPipelineEditorDrawer = () => wrapper.findComponent(PipelineEditorDrawer);
|
||||
const findPipelineEditorHeader = () => wrapper.findComponent(PipelineEditorHeader);
|
||||
const findPipelineEditorTabs = () => wrapper.findComponent(PipelineEditorTabs);
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
|
@ -55,6 +63,10 @@ describe('Pipeline editor home wrapper', () => {
|
|||
it('shows the commit section by default', () => {
|
||||
expect(findCommitSection().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('show the pipeline drawer', () => {
|
||||
expect(findPipelineEditorDrawer().exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('commit form toggle', () => {
|
||||
|
@ -82,4 +94,12 @@ describe('Pipeline editor home wrapper', () => {
|
|||
expect(findCommitSection().exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pipeline drawer', () => {
|
||||
it('hides the drawer when the feature flag is off', () => {
|
||||
createComponent({ glFeatures: { pipelineEditorDrawer: false } });
|
||||
|
||||
expect(findPipelineEditorDrawer().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,7 +7,10 @@ RSpec.describe Resolvers::MetadataResolver do
|
|||
|
||||
describe '#resolve' do
|
||||
it 'returns version and revision' do
|
||||
expect(resolve(described_class)).to have_attributes(version: Gitlab::VERSION, revision: Gitlab.revision)
|
||||
expect(resolve(described_class)).to have_attributes(
|
||||
version: Gitlab::VERSION,
|
||||
revision: Gitlab.revision,
|
||||
kas: kind_of(InstanceMetadata::Kas))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
8
spec/graphql/types/metadata/kas_type_spec.rb
Normal file
8
spec/graphql/types/metadata/kas_type_spec.rb
Normal file
|
@ -0,0 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe GitlabSchema.types['Kas'] do
|
||||
specify { expect(described_class.graphql_name).to eq('Kas') }
|
||||
specify { expect(described_class).to require_graphql_authorizations(:read_instance_metadata) }
|
||||
end
|
|
@ -33,6 +33,46 @@ RSpec.describe Gitlab::Kas do
|
|||
end
|
||||
end
|
||||
|
||||
describe '.enabled?' do
|
||||
before do
|
||||
allow(Gitlab).to receive(:config).and_return(gitlab_config)
|
||||
end
|
||||
|
||||
subject { described_class.enabled? }
|
||||
|
||||
context 'gitlab_config is not enabled' do
|
||||
let(:gitlab_config) { { 'gitlab_kas' => { 'enabled' => false } } }
|
||||
|
||||
it { is_expected.to be_falsey }
|
||||
end
|
||||
|
||||
context 'gitlab_config is enabled' do
|
||||
let(:gitlab_config) { { 'gitlab_kas' => { 'enabled' => true } } }
|
||||
|
||||
it { is_expected.to be_truthy }
|
||||
end
|
||||
|
||||
context 'enabled is unset' do
|
||||
let(:gitlab_config) { { 'gitlab_kas' => {} } }
|
||||
|
||||
it { is_expected.to be_falsey }
|
||||
end
|
||||
end
|
||||
|
||||
describe '.external_url' do
|
||||
it 'returns gitlab_kas external_url config' do
|
||||
expect(described_class.external_url).to eq(Gitlab.config.gitlab_kas.external_url)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.version' do
|
||||
it 'returns gitlab_kas version config' do
|
||||
version_file = Rails.root.join(described_class::VERSION_FILE)
|
||||
|
||||
expect(described_class.version).to eq(version_file.read.chomp)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.ensure_secret!' do
|
||||
context 'secret file exists' do
|
||||
before do
|
||||
|
|
33
spec/models/instance_metadata/kas_spec.rb
Normal file
33
spec/models/instance_metadata/kas_spec.rb
Normal file
|
@ -0,0 +1,33 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe ::InstanceMetadata::Kas do
|
||||
it 'has InstanceMetadataPolicy as declarative policy' do
|
||||
expect(described_class.declarative_policy_class).to eq("InstanceMetadataPolicy")
|
||||
end
|
||||
|
||||
context 'when KAS is enabled' do
|
||||
it 'has the correct properties' do
|
||||
allow(Gitlab::Kas).to receive(:enabled?).and_return(true)
|
||||
|
||||
expect(subject).to have_attributes(
|
||||
enabled: Gitlab::Kas.enabled?,
|
||||
version: Gitlab::Kas.version,
|
||||
external_url: Gitlab::Kas.external_url
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when KAS is disabled' do
|
||||
it 'has the correct properties' do
|
||||
allow(Gitlab::Kas).to receive(:enabled?).and_return(false)
|
||||
|
||||
expect(subject).to have_attributes(
|
||||
enabled: Gitlab::Kas.enabled?,
|
||||
version: nil,
|
||||
external_url: nil
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -6,7 +6,8 @@ RSpec.describe InstanceMetadata do
|
|||
it 'has the correct properties' do
|
||||
expect(subject).to have_attributes(
|
||||
version: Gitlab::VERSION,
|
||||
revision: Gitlab.revision
|
||||
revision: Gitlab.revision,
|
||||
kas: kind_of(::InstanceMetadata::Kas)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
49
spec/presenters/ci/stage_presenter_spec.rb
Normal file
49
spec/presenters/ci/stage_presenter_spec.rb
Normal file
|
@ -0,0 +1,49 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Ci::StagePresenter do
|
||||
let(:stage) { create(:ci_stage) }
|
||||
let(:presenter) { described_class.new(stage) }
|
||||
|
||||
let!(:build) { create(:ci_build, :tags, :artifacts, pipeline: stage.pipeline, stage: stage.name) }
|
||||
let!(:retried_build) { create(:ci_build, :tags, :artifacts, :retried, pipeline: stage.pipeline, stage: stage.name) }
|
||||
|
||||
before do
|
||||
create(:generic_commit_status, pipeline: stage.pipeline, stage: stage.name)
|
||||
end
|
||||
|
||||
shared_examples 'preloaded associations for CI status' do
|
||||
it 'preloads project' do
|
||||
expect(presented_stage.association(:project)).to be_loaded
|
||||
end
|
||||
|
||||
it 'preloads build pipeline' do
|
||||
expect(presented_stage.association(:pipeline)).to be_loaded
|
||||
end
|
||||
|
||||
it 'preloads build tags' do
|
||||
expect(presented_stage.association(:tags)).to be_loaded
|
||||
end
|
||||
|
||||
it 'preloads build artifacts archive' do
|
||||
expect(presented_stage.association(:job_artifacts_archive)).to be_loaded
|
||||
end
|
||||
|
||||
it 'preloads build artifacts metadata' do
|
||||
expect(presented_stage.association(:metadata)).to be_loaded
|
||||
end
|
||||
end
|
||||
|
||||
describe '#latest_ordered_statuses' do
|
||||
subject(:presented_stage) { presenter.latest_ordered_statuses.second }
|
||||
|
||||
it_behaves_like 'preloaded associations for CI status'
|
||||
end
|
||||
|
||||
describe '#retried_ordered_statuses' do
|
||||
subject(:presented_stage) { presenter.retried_ordered_statuses.first }
|
||||
|
||||
it_behaves_like 'preloaded associations for CI status'
|
||||
end
|
||||
end
|
|
@ -8,16 +8,48 @@ RSpec.describe 'getting project information' do
|
|||
let(:query) { graphql_query_for('metadata', {}, all_graphql_fields_for('Metadata')) }
|
||||
|
||||
context 'logged in' do
|
||||
it 'returns version and revision' do
|
||||
post_graphql(query, current_user: create(:user))
|
||||
|
||||
expect(graphql_errors).to be_nil
|
||||
expect(graphql_data).to eq(
|
||||
let(:expected_data) do
|
||||
{
|
||||
'metadata' => {
|
||||
'version' => Gitlab::VERSION,
|
||||
'revision' => Gitlab.revision
|
||||
'revision' => Gitlab.revision,
|
||||
'kas' => {
|
||||
'enabled' => Gitlab::Kas.enabled?,
|
||||
'version' => expected_kas_version,
|
||||
'externalUrl' => expected_kas_external_url
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
end
|
||||
|
||||
context 'kas is enabled' do
|
||||
let(:expected_kas_version) { Gitlab::Kas.version }
|
||||
let(:expected_kas_external_url) { Gitlab::Kas.external_url }
|
||||
|
||||
before do
|
||||
allow(Gitlab::Kas).to receive(:enabled?).and_return(true)
|
||||
post_graphql(query, current_user: create(:user))
|
||||
end
|
||||
|
||||
it 'returns version, revision, kas_enabled, kas_version, kas_external_url' do
|
||||
expect(graphql_errors).to be_nil
|
||||
expect(graphql_data).to eq(expected_data)
|
||||
end
|
||||
end
|
||||
|
||||
context 'kas is disabled' do
|
||||
let(:expected_kas_version) { nil }
|
||||
let(:expected_kas_external_url) { nil }
|
||||
|
||||
before do
|
||||
allow(Gitlab::Kas).to receive(:enabled?).and_return(false)
|
||||
post_graphql(query, current_user: create(:user))
|
||||
end
|
||||
|
||||
it 'returns version and revision' do
|
||||
expect(graphql_errors).to be_nil
|
||||
expect(graphql_data).to eq(expected_data)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -170,15 +170,18 @@ RSpec.shared_context 'group navbar structure' do
|
|||
}
|
||||
end
|
||||
|
||||
let(:group_information_nav_item) do
|
||||
{
|
||||
nav_item: _('Group information'),
|
||||
nav_sub_items: [
|
||||
_('Activity')
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
let(:structure) do
|
||||
[
|
||||
{
|
||||
nav_item: _('Group overview'),
|
||||
nav_sub_items: [
|
||||
_('Details'),
|
||||
_('Activity')
|
||||
]
|
||||
},
|
||||
group_information_nav_item,
|
||||
{
|
||||
nav_item: _('Issues'),
|
||||
nav_sub_items: [
|
||||
|
|
|
@ -298,15 +298,15 @@ RSpec.describe 'gitlab:db namespace rake task' do
|
|||
end
|
||||
|
||||
describe '#migrate_with_instrumentation' do
|
||||
subject { run_rake_task('gitlab:db:migration_testing', "[#{filename}]") }
|
||||
subject { run_rake_task('gitlab:db:migration_testing') }
|
||||
|
||||
let(:ctx) { double('ctx', migrations: all_migrations, schema_migration: double, get_all_versions: existing_versions) }
|
||||
let(:instrumentation) { instance_double(Gitlab::Database::Migrations::Instrumentation, observations: observations) }
|
||||
let(:existing_versions) { [1] }
|
||||
let(:all_migrations) { [double('migration1', version: 1), pending_migration] }
|
||||
let(:pending_migration) { double('migration2', version: 2) }
|
||||
let(:filename) { 'results-file.json'}
|
||||
let(:buffer) { StringIO.new }
|
||||
let(:filename) { Gitlab::Database::Migrations::Instrumentation::STATS_FILENAME }
|
||||
let!(:directory) { Dir.mktmpdir }
|
||||
let(:observations) { %w[some data] }
|
||||
|
||||
before do
|
||||
|
@ -316,17 +316,19 @@ RSpec.describe 'gitlab:db namespace rake task' do
|
|||
|
||||
allow(instrumentation).to receive(:observe).and_yield
|
||||
|
||||
allow(File).to receive(:open).with(filename, 'wb+').and_yield(buffer)
|
||||
allow(Dir).to receive(:mkdir)
|
||||
allow(File).to receive(:exist?).with(directory).and_return(false)
|
||||
stub_const('Gitlab::Database::Migrations::Instrumentation::RESULT_DIR', directory)
|
||||
end
|
||||
|
||||
it 'fails when given no filename argument' do
|
||||
expect { run_rake_task('gitlab:db:migration_testing') }.to raise_error(/specify result_file/)
|
||||
after do
|
||||
FileUtils.rm_rf([directory])
|
||||
end
|
||||
|
||||
it 'fails when the given file already exists' do
|
||||
expect(File).to receive(:exist?).with(filename).and_return(true)
|
||||
it 'fails when the directory already exists' do
|
||||
expect(File).to receive(:exist?).with(directory).and_return(true)
|
||||
|
||||
expect { subject }.to raise_error(/File exists/)
|
||||
expect { subject }.to raise_error(/Directory exists/)
|
||||
end
|
||||
|
||||
it 'instruments the pending migration' do
|
||||
|
@ -344,7 +346,7 @@ RSpec.describe 'gitlab:db namespace rake task' do
|
|||
it 'writes observations out to JSON file' do
|
||||
subject
|
||||
|
||||
expect(buffer.string).to eq(observations.to_json)
|
||||
expect(File.read(File.join(directory, filename))).to eq(observations.to_json)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
require 'spec_helper'
|
||||
|
||||
RSpec.describe 'layouts/nav/sidebar/_group' do
|
||||
let(:group) { create(:group) }
|
||||
let_it_be(:group) { create(:group) }
|
||||
|
||||
before do
|
||||
assign(:group, group)
|
||||
|
@ -11,4 +11,36 @@ RSpec.describe 'layouts/nav/sidebar/_group' do
|
|||
|
||||
it_behaves_like 'has nav sidebar'
|
||||
it_behaves_like 'sidebar includes snowplow attributes', 'render', 'groups_side_navigation', 'groups_side_navigation'
|
||||
|
||||
describe 'Group information' do
|
||||
it 'has a link to the group path' do
|
||||
render
|
||||
|
||||
expect(rendered).to have_link('Group information', href: group_path(group))
|
||||
end
|
||||
|
||||
it 'does not have a link to the details menu item' do
|
||||
render
|
||||
|
||||
expect(rendered).not_to have_link('Details', href: details_group_path(group))
|
||||
end
|
||||
|
||||
context 'when feature flag :sidebar_refactor is disabled' do
|
||||
before do
|
||||
stub_feature_flags(sidebar_refactor: false)
|
||||
end
|
||||
|
||||
it 'has a link to the group path with the "Group overview" title' do
|
||||
render
|
||||
|
||||
expect(rendered).to have_link('Group overview', href: group_path(group))
|
||||
end
|
||||
|
||||
it 'has a link to the details menu item' do
|
||||
render
|
||||
|
||||
expect(rendered).to have_link('Details', href: details_group_path(group))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -907,10 +907,10 @@
|
|||
resolved "https://registry.yarnpkg.com/@gitlab/tributejs/-/tributejs-1.0.0.tgz#672befa222aeffc83e7d799b0500a7a4418e59b8"
|
||||
integrity sha512-nmKw1+hB6MHvlmPz63yPwVs1qQkycHwsKgxpEbzmky16Y6mL4EJMk3w1b8QlOAF/AIAzjCERPhe/R4MJiohbZw==
|
||||
|
||||
"@gitlab/ui@29.13.0":
|
||||
version "29.13.0"
|
||||
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-29.13.0.tgz#6e222106a0ae14f56c361b0cc86152d09170cc09"
|
||||
integrity sha512-JZAIuYT9gUhv/My/+IVwbBacTJAL+9g7wZWfSl9DS8PY/H2GCGgMcgvcSJMDuqcJZvKZdNkQ0XzXem+SFo5t1A==
|
||||
"@gitlab/ui@29.14.0":
|
||||
version "29.14.0"
|
||||
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-29.14.0.tgz#0b5dc564fa26194ddbea6fe78418dc46c0e557ac"
|
||||
integrity sha512-SYRokscvZD/F0TFa2gc0CgBtLeBlv4mPDhGPQUvh6uaX68NgMx9CstfYb286j5dKlvqBw+7r83fMiAHEzpberw==
|
||||
dependencies:
|
||||
"@babel/standalone" "^7.0.0"
|
||||
"@gitlab/vue-toasted" "^1.3.0"
|
||||
|
|
Loading…
Reference in a new issue