Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
bd84e48794
commit
9d5c1efbd1
|
|
@ -402,6 +402,7 @@ Dangerfile
|
|||
/spec/frontend/diffs/store/
|
||||
|
||||
^[Product Analytics] @gitlab-org/analytics-section/product-analytics/engineers/frontend
|
||||
/app/assets/javascripts/vue_shared/components/customizable_dashboard/
|
||||
/ee/app/assets/javascripts/analytics/analytics_dashboards/components/analytics_dashboard.vue
|
||||
/ee/app/assets/javascripts/analytics/analytics_dashboards/components/analytics_visualization_designer.vue
|
||||
/ee/app/assets/javascripts/analytics/analytics_dashboards/components/dashboards_list.vue
|
||||
|
|
@ -415,10 +416,6 @@ Dangerfile
|
|||
/ee/app/assets/javascripts/analytics/analytics_dashboards/router.js
|
||||
/ee/app/assets/javascripts/analytics/analytics_dashboards/constants.js
|
||||
/ee/app/assets/javascripts/analytics/analytics_dashboards/graphql/queries/get_all_customizable_dashboards.query.graphql
|
||||
/ee/app/assets/javascripts/analytics/analytics_dashboards/graphql/queries/get_customizable_dashboard.query.graphql
|
||||
/ee/app/assets/javascripts/analytics/analytics_dashboards/graphql/queries/get_all_customizable_visualizations.query.graphql
|
||||
/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/customizable_dashboard.vue
|
||||
/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/panels_base.vue
|
||||
/ee/app/assets/javascripts/product_analytics/
|
||||
|
||||
^[Analytics Instrumentation] @gitlab-org/analytics-section/analytics-instrumentation/engineers
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ and cross-posted (with the command results) to the responsible team's Slack chan
|
|||
Cross link the issue here if it does.
|
||||
- [ ] Ensure that you or a representative in development can be available for at least 2 hours after feature flag updates in production.
|
||||
If a different developer will be covering, or an exception is needed, please inform the oncall SRE by using the `@sre-oncall` Slack alias.
|
||||
- [ ] Ensure that documentation exists for the feature, and the [version history text](https://docs.gitlab.com/ee/development/documentation/feature_flags.html#add-version-history-text) has been updated.
|
||||
- [ ] Ensure that documentation exists for the feature, and the [version history text](https://docs.gitlab.com/ee/development/documentation/feature_flags.html#add-history-text) has been updated.
|
||||
- [ ] Ensure that any breaking changes have been announced following the [release post process](https://about.gitlab.com/handbook/marketing/blog/release-posts/#deprecations-removals-and-breaking-changes) to ensure GitLab customers are aware.
|
||||
- [ ] Notify the [`#support_gitlab-com` Slack channel](https://gitlab.slack.com/archives/C4XFU81LG) and your team channel ([more guidance when this is necessary in the dev docs](https://docs.gitlab.com/ee/development/feature_flags/controls.html#communicate-the-change)).
|
||||
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ export default {
|
|||
helpUrl: helpPagePath('user/group/import/index', {
|
||||
anchor: 'visibility-rules',
|
||||
}),
|
||||
shouldMigrateMemberships: true,
|
||||
};
|
||||
},
|
||||
|
||||
|
|
@ -462,6 +463,7 @@ export default {
|
|||
sourceGroupId: group.id,
|
||||
targetNamespace: group.importTarget.targetNamespace.fullPath,
|
||||
newName: group.importTarget.newName,
|
||||
migrateMemberships: this.shouldMigrateMemberships,
|
||||
...extraArgs,
|
||||
},
|
||||
]);
|
||||
|
|
@ -485,6 +487,7 @@ export default {
|
|||
sourceGroupId: group.id,
|
||||
targetNamespace: group.importTarget.targetNamespace.fullPath,
|
||||
newName: group.importTarget.newName,
|
||||
migrateMemberships: this.shouldMigrateMemberships,
|
||||
...extraArgs,
|
||||
}));
|
||||
|
||||
|
|
@ -650,6 +653,7 @@ export default {
|
|||
permissionsHelpPath: helpPagePath('user/permissions', { anchor: 'group-members-permissions' }),
|
||||
betaFeatureHelpPath: helpPagePath('policy/experiment-beta-support', { anchor: 'beta-features' }),
|
||||
popoverOptions: { title: __('What is listed here?') },
|
||||
learnMoreOptions: { title: s__('BulkImport|Import user memberships') },
|
||||
i18n,
|
||||
LOCAL_STORAGE_KEY: 'gl-bulk-imports-status-page-size-v1',
|
||||
};
|
||||
|
|
@ -787,40 +791,60 @@ export default {
|
|||
</gl-empty-state>
|
||||
<template v-else>
|
||||
<div
|
||||
class="import-table-bar gl-sticky gl-z-3 gl-flex gl-items-center gl-border-0 gl-border-b-1 gl-border-solid gl-border-gray-200 gl-bg-gray-10 gl-px-4"
|
||||
class="import-table-bar gl-sticky gl-z-3 gl-flex gl-flex-col gl-border-0 gl-border-b-1 gl-border-solid gl-border-gray-200 gl-bg-gray-10 gl-px-4 md:gl-flex-row md:gl-items-center md:gl-justify-between"
|
||||
>
|
||||
<span data-test-id="selection-count">
|
||||
<gl-sprintf :message="__('%{count} selected')">
|
||||
<template #count>
|
||||
{{ selectedGroupsIds.length }}
|
||||
</template>
|
||||
</gl-sprintf>
|
||||
</span>
|
||||
<gl-dropdown
|
||||
:text="s__('BulkImport|Import with projects')"
|
||||
:disabled="!hasSelectedGroups"
|
||||
variant="confirm"
|
||||
category="primary"
|
||||
data-testid="import-selected-groups-dropdown"
|
||||
class="gl-ml-4"
|
||||
split
|
||||
@click="importSelectedGroups({ migrateProjects: true })"
|
||||
>
|
||||
<gl-dropdown-item @click="importSelectedGroups({ migrateProjects: false })">
|
||||
{{ s__('BulkImport|Import without projects') }}
|
||||
</gl-dropdown-item>
|
||||
</gl-dropdown>
|
||||
<span v-if="showImportProjectsWarning" class="gl-ml-3">
|
||||
<gl-icon
|
||||
v-gl-tooltip
|
||||
:title="s__('BulkImport|Some groups will be imported without projects.')"
|
||||
name="warning"
|
||||
class="gl-text-orange-500"
|
||||
data-testid="import-projects-warning"
|
||||
/>
|
||||
</span>
|
||||
<div class="gl-mb-3 gl-flex gl-items-center gl-pr-6 md:gl-mb-0 md:gl-grow">
|
||||
<span data-test-id="selection-count">
|
||||
<gl-sprintf :message="__('%{count} selected')">
|
||||
<template #count>
|
||||
{{ selectedGroupsIds.length }}
|
||||
</template>
|
||||
</gl-sprintf>
|
||||
</span>
|
||||
<gl-dropdown
|
||||
:text="s__('BulkImport|Import with projects')"
|
||||
:disabled="!hasSelectedGroups"
|
||||
variant="confirm"
|
||||
category="primary"
|
||||
data-testid="import-selected-groups-dropdown"
|
||||
class="gl-ml-4 gl-shrink"
|
||||
split
|
||||
@click="importSelectedGroups({ migrateProjects: true })"
|
||||
>
|
||||
<gl-dropdown-item @click="importSelectedGroups({ migrateProjects: false })">
|
||||
{{ s__('BulkImport|Import without projects') }}
|
||||
</gl-dropdown-item>
|
||||
</gl-dropdown>
|
||||
<span v-if="showImportProjectsWarning" class="gl-ml-3 gl-shrink-0">
|
||||
<gl-icon
|
||||
v-gl-tooltip
|
||||
:title="s__('BulkImport|Some groups will be imported without projects.')"
|
||||
name="warning"
|
||||
class="gl-text-orange-500"
|
||||
data-testid="import-projects-warning"
|
||||
/>
|
||||
</span>
|
||||
<div class="gl-ml-4 gl-flex">
|
||||
<gl-form-checkbox
|
||||
v-model="shouldMigrateMemberships"
|
||||
data-testid="toggle-import-user-memberships"
|
||||
class="gl-ml-4 gl-mr-2 gl-pt-1"
|
||||
>
|
||||
{{ s__('BulkImport|Import user memberships') }}
|
||||
</gl-form-checkbox>
|
||||
<help-popover :options="$options.learnMoreOptions">
|
||||
<gl-sprintf
|
||||
:message="
|
||||
s__(
|
||||
'BulkImport|Select whether user memberships in groups and projects are imported.',
|
||||
)
|
||||
"
|
||||
/>
|
||||
</help-popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="gl-ml-3">
|
||||
<span class="gl-leading-20">
|
||||
<gl-icon name="information-o" :size="12" class="gl-text-blue-600" />
|
||||
<gl-sprintf
|
||||
:message="
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export const i18n = {
|
|||
|
||||
NO_GROUPS_FOUND: s__('BulkImport|No groups found'),
|
||||
OWNER: __('Owner'),
|
||||
LEARN_MORE: __('Learn more.'),
|
||||
|
||||
features: {
|
||||
projectMigration: __('projects'),
|
||||
|
|
|
|||
|
|
@ -159,6 +159,7 @@ export function createResolvers({ endpoints }) {
|
|||
destination_namespace: op.targetNamespace,
|
||||
destination_name: op.newName,
|
||||
migrate_projects: op.migrateProjects,
|
||||
migrate_memberships: op.migrateMemberships,
|
||||
})),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ input ImportRequestInput {
|
|||
targetNamespace: String!
|
||||
newName: String!
|
||||
migrateProjects: Boolean!
|
||||
migrateMemberships: Boolean!
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import { breakpoints } from '@gitlab/ui/dist/utils';
|
||||
|
||||
export const GRIDSTACK_MARGIN = 8;
|
||||
export const GRIDSTACK_CSS_HANDLE = '.grid-stack-item-handle';
|
||||
|
||||
/* Magic number 125px:
|
||||
* After allowing for padding, and the panel title row, this leaves us with minimum 48px height for the cell content.
|
||||
* This means text/content with our spacing scale can fit up to 49px without scrolling.
|
||||
*/
|
||||
export const GRIDSTACK_CELL_HEIGHT = '125px';
|
||||
export const GRIDSTACK_MIN_ROW = 1;
|
||||
|
||||
export const GRIDSTACK_BASE_CONFIG = {
|
||||
margin: GRIDSTACK_MARGIN,
|
||||
handle: GRIDSTACK_CSS_HANDLE,
|
||||
cellHeight: GRIDSTACK_CELL_HEIGHT,
|
||||
minRow: GRIDSTACK_MIN_ROW,
|
||||
columnOpts: { breakpoints: [{ w: breakpoints.md, c: 1 }] },
|
||||
alwaysShowResizeHandle: true,
|
||||
animate: true,
|
||||
float: true,
|
||||
};
|
||||
|
||||
export const PANEL_POPOVER_DELAY = {
|
||||
hide: 500,
|
||||
};
|
||||
|
||||
export const CURSOR_GRABBING_CLASS = '!gl-cursor-grabbing';
|
||||
|
||||
export const NEW_DASHBOARD_SLUG = 'new';
|
||||
|
||||
export const CATEGORY_SINGLE_STATS = 'singleStats';
|
||||
export const CATEGORY_TABLES = 'tables';
|
||||
export const CATEGORY_CHARTS = 'charts';
|
||||
|
||||
export const DASHBOARD_STATUS_BETA = 'beta';
|
||||
export const DASHBOARD_SCHEMA_VERSION = '2';
|
||||
export const VISUALIZATION_TYPE_DATA_TABLE = 'DataTable';
|
||||
export const VISUALIZATION_TYPE_LINE_CHART = 'LineChart';
|
||||
export const VISUALIZATION_TYPE_COLUMN_CHART = 'ColumnChart';
|
||||
export const VISUALIZATION_TYPE_SINGLE_STAT = 'SingleStat';
|
||||
|
||||
export const EVENT_LABEL_VIEWED_DASHBOARD_DESIGNER = 'user_viewed_dashboard_designer';
|
||||
export const EVENT_LABEL_EXCLUDE_ANONYMISED_USERS = 'exclude_anonymised_users';
|
||||
|
||||
export const AI_IMPACT_DASHBOARD = 'ai_impact';
|
||||
|
||||
// The URL name already in use is `value_streams_dashboard`,
|
||||
// the slug name for a dashboard must match the URL path that is used
|
||||
export const BUILT_IN_VALUE_STREAM_DASHBOARD = 'value_streams_dashboard';
|
||||
|
||||
// The URL for shared analytics dashboards is based on the name of the YAML config
|
||||
// YAML configured VSD uses `/value_streams` for the custom file name
|
||||
export const CUSTOM_VALUE_STREAM_DASHBOARD = 'value_streams';
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { s__ } from '~/locale';
|
||||
import CustomizableDashboard from './customizable_dashboard.vue';
|
||||
|
||||
export default {
|
||||
component: CustomizableDashboard,
|
||||
title: '~/vue_shared/components/customizable_dashboard',
|
||||
};
|
||||
|
||||
const Template = (args, { argTypes }) => ({
|
||||
components: { CustomizableDashboard },
|
||||
props: Object.keys(argTypes),
|
||||
template: '<customizable-dashboard v-bind="$props" />',
|
||||
});
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {
|
||||
editable: false,
|
||||
panels: [
|
||||
{
|
||||
component: 'CubeLineChart',
|
||||
title: s__('ProductAnalytics|Audience'),
|
||||
gridAttributes: {
|
||||
width: 3,
|
||||
height: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'CubeLineChart',
|
||||
title: s__('ProductAnalytics|Audience'),
|
||||
gridAttributes: {
|
||||
width: 3,
|
||||
height: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const Editable = Template.bind({});
|
||||
Editable.args = {
|
||||
...Default.args,
|
||||
editable: true,
|
||||
};
|
||||
|
|
@ -0,0 +1,544 @@
|
|||
<script>
|
||||
import {
|
||||
GlButton,
|
||||
GlFormInput,
|
||||
GlFormGroup,
|
||||
GlLink,
|
||||
GlIcon,
|
||||
GlSprintf,
|
||||
GlExperimentBadge,
|
||||
} from '@gitlab/ui';
|
||||
import { isEqual } from 'lodash';
|
||||
import { createAlert } from '~/alert';
|
||||
import { cloneWithoutReferences } from '~/lib/utils/common_utils';
|
||||
import { slugify } from '~/lib/utils/text_utility';
|
||||
import { s__, __ } from '~/locale';
|
||||
import { InternalEvents } from '~/tracking';
|
||||
import UrlSync, { HISTORY_REPLACE_UPDATE_METHOD } from '~/vue_shared/components/url_sync.vue';
|
||||
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
|
||||
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
|
||||
import { helpPagePath } from '~/helpers/help_page_helper';
|
||||
import {
|
||||
EVENT_LABEL_VIEWED_DASHBOARD_DESIGNER,
|
||||
EVENT_LABEL_EXCLUDE_ANONYMISED_USERS,
|
||||
DASHBOARD_STATUS_BETA,
|
||||
AI_IMPACT_DASHBOARD,
|
||||
BUILT_IN_VALUE_STREAM_DASHBOARD,
|
||||
CUSTOM_VALUE_STREAM_DASHBOARD,
|
||||
} from './constants';
|
||||
import GridstackWrapper from './gridstack_wrapper.vue';
|
||||
import AvailableVisualizationsDrawer from './dashboard_editor/available_visualizations_drawer.vue';
|
||||
import {
|
||||
getDashboardConfig,
|
||||
filtersToQueryParams,
|
||||
availableVisualizationsValidator,
|
||||
createNewVisualizationPanel,
|
||||
} from './utils';
|
||||
|
||||
export default {
|
||||
name: 'CustomizableDashboard',
|
||||
components: {
|
||||
DateRangeFilter: () => import('./filters/date_range_filter.vue'),
|
||||
AnonUsersFilter: () => import('./filters/anon_users_filter.vue'),
|
||||
GlButton,
|
||||
GlFormInput,
|
||||
GlIcon,
|
||||
GlLink,
|
||||
GlFormGroup,
|
||||
GlSprintf,
|
||||
GlExperimentBadge,
|
||||
UrlSync,
|
||||
AvailableVisualizationsDrawer,
|
||||
GridstackWrapper,
|
||||
},
|
||||
mixins: [InternalEvents.mixin(), glFeatureFlagsMixin()],
|
||||
props: {
|
||||
initialDashboard: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => {},
|
||||
},
|
||||
availableVisualizations: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => {},
|
||||
validator: availableVisualizationsValidator,
|
||||
},
|
||||
dateRangeLimit: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 0,
|
||||
},
|
||||
showDateRangeFilter: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
showAnonUsersFilter: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
defaultFilters: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => {},
|
||||
},
|
||||
syncUrlFilters: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
isSaving: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
changesSaved: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
isNewDashboard: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
titleValidationError: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
dashboard: this.createDraftDashboard(this.initialDashboard),
|
||||
editing: this.isNewDashboard,
|
||||
filters: this.defaultFilters,
|
||||
alert: null,
|
||||
visualizationDrawerOpen: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
showFilters() {
|
||||
return !this.editing && (this.showDateRangeFilter || this.showAnonUsersFilter);
|
||||
},
|
||||
queryParams() {
|
||||
return this.showFilters ? filtersToQueryParams(this.filters) : {};
|
||||
},
|
||||
editingEnabled() {
|
||||
return (
|
||||
this.dashboard.userDefined &&
|
||||
(this.dashboard.slug !== CUSTOM_VALUE_STREAM_DASHBOARD ||
|
||||
this.glFeatures.enableVsdVisualEditor)
|
||||
);
|
||||
},
|
||||
showEditControls() {
|
||||
return this.editingEnabled && this.editing;
|
||||
},
|
||||
showDashboardDescription() {
|
||||
return Boolean(this.dashboard.description) && !this.editing;
|
||||
},
|
||||
showEditDashboardButton() {
|
||||
return this.editingEnabled && !this.editing;
|
||||
},
|
||||
showBetaBadge() {
|
||||
return this.dashboard.status === DASHBOARD_STATUS_BETA;
|
||||
},
|
||||
dashboardDescription() {
|
||||
return this.dashboard.description;
|
||||
},
|
||||
changesMade() {
|
||||
// Compare the dashboard configs as that is what will be saved
|
||||
return !isEqual(
|
||||
getDashboardConfig(this.initialDashboard),
|
||||
getDashboardConfig(this.dashboard),
|
||||
);
|
||||
},
|
||||
isValueStreamsDashboard() {
|
||||
return this.dashboard.slug === BUILT_IN_VALUE_STREAM_DASHBOARD;
|
||||
},
|
||||
isAiImpactDashboard() {
|
||||
return this.dashboard.slug === AI_IMPACT_DASHBOARD;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
isNewDashboard(isNew) {
|
||||
this.editing = isNew;
|
||||
},
|
||||
changesSaved: {
|
||||
handler(saved) {
|
||||
if (saved && this.editing) {
|
||||
this.editing = false;
|
||||
}
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
'$route.params.editing': {
|
||||
handler(editing) {
|
||||
if (editing !== undefined) {
|
||||
this.editing = editing;
|
||||
}
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
editing: {
|
||||
handler(editing) {
|
||||
this.grid?.setStatic(!editing);
|
||||
if (!editing) {
|
||||
this.closeVisualizationDrawer();
|
||||
} else {
|
||||
this.trackEvent(EVENT_LABEL_VIEWED_DASHBOARD_DESIGNER);
|
||||
}
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
initialDashboard() {
|
||||
this.resetToInitialDashboard();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
const wrappers = document.querySelectorAll('.container-fluid.container-limited');
|
||||
|
||||
wrappers.forEach((el) => {
|
||||
el.classList.add('not-container-limited');
|
||||
el.classList.remove('container-limited');
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', this.onPageUnload);
|
||||
},
|
||||
beforeDestroy() {
|
||||
const wrappers = document.querySelectorAll('.container-fluid.not-container-limited');
|
||||
|
||||
wrappers.forEach((el) => {
|
||||
el.classList.add('container-limited');
|
||||
el.classList.remove('not-container-limited');
|
||||
});
|
||||
|
||||
this.alert?.dismiss();
|
||||
|
||||
window.removeEventListener('beforeunload', this.onPageUnload);
|
||||
},
|
||||
methods: {
|
||||
onPageUnload(event) {
|
||||
if (!this.changesMade) return undefined;
|
||||
|
||||
event.preventDefault();
|
||||
// This returnValue is required on some browsers. This message is displayed on older versions.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event#compatibility_notes
|
||||
const returnValue = __('Are you sure you want to lose unsaved changes?');
|
||||
Object.assign(event, { returnValue });
|
||||
return returnValue;
|
||||
},
|
||||
createDraftDashboard(dashboard) {
|
||||
return cloneWithoutReferences(dashboard);
|
||||
},
|
||||
resetToInitialDashboard() {
|
||||
this.dashboard = this.createDraftDashboard(this.initialDashboard);
|
||||
},
|
||||
onTitleInput(submitting) {
|
||||
this.$emit('title-input', this.dashboard.title, submitting);
|
||||
},
|
||||
startEdit() {
|
||||
this.editing = true;
|
||||
},
|
||||
async saveEdit() {
|
||||
if (this.titleValidationError === null && this.isNewDashboard) {
|
||||
// ensure validation gets run when form is submitted with an empty title
|
||||
this.onTitleInput(true);
|
||||
this.$refs.titleInput.$el.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.titleValidationError) {
|
||||
this.$refs.titleInput.$el.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isNewDashboard && this.dashboard.panels.length < 1) {
|
||||
this.alert = createAlert({
|
||||
message: s__('Analytics|Add a visualization'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.alert?.dismiss();
|
||||
|
||||
if (this.isNewDashboard) {
|
||||
this.dashboard.slug = slugify(this.dashboard.title, '_');
|
||||
}
|
||||
|
||||
this.$emit('save', this.dashboard.slug, this.dashboard);
|
||||
},
|
||||
async confirmDiscardIfChanged() {
|
||||
// Implicityly confirm if no changes were made
|
||||
if (!this.changesMade) return true;
|
||||
|
||||
// No need to confirm while saving
|
||||
if (this.isSaving) return true;
|
||||
|
||||
return this.confirmDiscardChanges();
|
||||
},
|
||||
async cancelEdit() {
|
||||
if (this.changesMade) {
|
||||
const confirmed = await this.confirmDiscardChanges();
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
this.resetToInitialDashboard();
|
||||
}
|
||||
|
||||
if (this.isNewDashboard) {
|
||||
this.$router.push('/');
|
||||
return;
|
||||
}
|
||||
|
||||
this.editing = false;
|
||||
},
|
||||
async confirmDiscardChanges() {
|
||||
const confirmText = this.isNewDashboard
|
||||
? s__('Analytics|Are you sure you want to cancel creating this dashboard?')
|
||||
: s__('Analytics|Are you sure you want to cancel editing this dashboard?');
|
||||
|
||||
const cancelBtnText = this.isNewDashboard
|
||||
? s__('Analytics|Continue creating')
|
||||
: s__('Analytics|Continue editing');
|
||||
|
||||
return confirmAction(confirmText, {
|
||||
primaryBtnText: __('Discard changes'),
|
||||
cancelBtnText,
|
||||
});
|
||||
},
|
||||
setDateRangeFilter({ dateRangeOption, startDate, endDate }) {
|
||||
this.filters = {
|
||||
...this.filters,
|
||||
dateRangeOption,
|
||||
startDate,
|
||||
endDate,
|
||||
};
|
||||
},
|
||||
setAnonymousUsersFilter(filterAnonUsers) {
|
||||
this.filters = {
|
||||
...this.filters,
|
||||
filterAnonUsers,
|
||||
};
|
||||
|
||||
if (filterAnonUsers) {
|
||||
this.trackEvent(EVENT_LABEL_EXCLUDE_ANONYMISED_USERS);
|
||||
}
|
||||
},
|
||||
toggleVisualizationDrawer() {
|
||||
this.visualizationDrawerOpen = !this.visualizationDrawerOpen;
|
||||
},
|
||||
closeVisualizationDrawer() {
|
||||
this.visualizationDrawerOpen = false;
|
||||
},
|
||||
deletePanel(panel) {
|
||||
const removeIndex = this.dashboard.panels.findIndex((p) => p.id === panel.id);
|
||||
this.dashboard.panels.splice(removeIndex, 1);
|
||||
},
|
||||
addPanels(visualizations) {
|
||||
this.closeVisualizationDrawer();
|
||||
|
||||
const panels = visualizations.map((viz) => createNewVisualizationPanel(viz));
|
||||
this.dashboard.panels.push(...panels);
|
||||
},
|
||||
},
|
||||
i18n: {
|
||||
alternativeAiImpactDescription: s__(
|
||||
'Analytics|Visualize the relation between AI usage and SDLC trends. Learn more about %{docsLinkStart}AI Impact analytics%{docsLinkEnd} and %{subscriptionLinkStart}GitLab Duo Pro seats usage%{subscriptionLinkEnd}.',
|
||||
),
|
||||
},
|
||||
HISTORY_REPLACE_UPDATE_METHOD,
|
||||
FORM_GROUP_CLASS: 'gl-w-full sm:gl-w-3/10 gl-min-w-20 gl-m-0',
|
||||
FORM_INPUT_CLASS: 'form-control gl-mr-4 gl-border-gray-200',
|
||||
VSD_DOCUMENTATION_LINK: helpPagePath('user/analytics/value_streams_dashboard'),
|
||||
AI_IMPACT_DOCUMENTATION_LINK: helpPagePath('user/analytics/ai_impact_analytics'),
|
||||
DUO_PRO_SUBSCRIPTION_ADD_ON_LINK: helpPagePath('subscriptions/subscription-add-ons', {
|
||||
anchor: 'assign-gitlab-duo-seats',
|
||||
}),
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<section class="gl-my-4 gl-flex gl-items-center">
|
||||
<div class="gl-flex gl-w-full gl-flex-col">
|
||||
<h2 v-if="showEditControls" data-testid="edit-mode-title" class="gl-mb-6 gl-mt-0">
|
||||
{{
|
||||
isNewDashboard
|
||||
? s__('Analytics|Create your dashboard')
|
||||
: s__('Analytics|Edit your dashboard')
|
||||
}}
|
||||
</h2>
|
||||
<div v-else class="gl-flex gl-items-center">
|
||||
<h2 data-testid="dashboard-title" class="gl-my-0">{{ dashboard.title }}</h2>
|
||||
<gl-experiment-badge v-if="showBetaBadge" class="gl-ml-3" type="beta" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showDashboardDescription"
|
||||
class="gl-mt-3 gl-flex"
|
||||
data-testid="dashboard-description"
|
||||
>
|
||||
<p class="gl-mb-0">
|
||||
<!-- TODO: Remove this alternative description in https://gitlab.com/gitlab-org/gitlab/-/issues/465569 -->
|
||||
<gl-sprintf
|
||||
v-if="isAiImpactDashboard"
|
||||
:message="$options.i18n.alternativeAiImpactDescription"
|
||||
>
|
||||
<template #docsLink="{ content }">
|
||||
<gl-link :href="$options.AI_IMPACT_DOCUMENTATION_LINK">{{ content }}</gl-link>
|
||||
</template>
|
||||
|
||||
<template #subscriptionLink="{ content }">
|
||||
<gl-link :href="$options.DUO_PRO_SUBSCRIPTION_ADD_ON_LINK">{{ content }}</gl-link>
|
||||
</template>
|
||||
</gl-sprintf>
|
||||
<template v-else>
|
||||
{{ dashboardDescription }}
|
||||
<!-- TODO: Remove this link in https://gitlab.com/gitlab-org/gitlab/-/issues/465569 -->
|
||||
<gl-sprintf
|
||||
v-if="isValueStreamsDashboard"
|
||||
:message="__('%{linkStart} Learn more%{linkEnd}.')"
|
||||
>
|
||||
<template #link="{ content }">
|
||||
<gl-link :href="$options.VSD_DOCUMENTATION_LINK">{{ content }}</gl-link>
|
||||
</template>
|
||||
</gl-sprintf>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="showEditControls" class="flex-fill gl-flex gl-flex-col">
|
||||
<gl-form-group
|
||||
:label="s__('Analytics|Dashboard title')"
|
||||
label-for="title"
|
||||
:class="$options.FORM_GROUP_CLASS"
|
||||
class="gl-mb-4"
|
||||
data-testid="dashboard-title-form-group"
|
||||
:invalid-feedback="titleValidationError"
|
||||
:state="!titleValidationError"
|
||||
>
|
||||
<gl-form-input
|
||||
id="title"
|
||||
ref="titleInput"
|
||||
v-model="dashboard.title"
|
||||
dir="auto"
|
||||
type="text"
|
||||
:placeholder="s__('Analytics|Enter a dashboard title')"
|
||||
:aria-label="s__('Analytics|Dashboard title')"
|
||||
:class="$options.FORM_INPUT_CLASS"
|
||||
data-testid="dashboard-title-input"
|
||||
:state="!titleValidationError"
|
||||
required
|
||||
@input="onTitleInput"
|
||||
/>
|
||||
</gl-form-group>
|
||||
<gl-form-group
|
||||
:label="s__('Analytics|Dashboard description (optional)')"
|
||||
label-for="description"
|
||||
:class="$options.FORM_GROUP_CLASS"
|
||||
>
|
||||
<gl-form-input
|
||||
id="description"
|
||||
v-model="dashboard.description"
|
||||
dir="auto"
|
||||
type="text"
|
||||
:placeholder="s__('Analytics|Enter a dashboard description')"
|
||||
:aria-label="s__('Analytics|Dashboard description')"
|
||||
:class="$options.FORM_INPUT_CLASS"
|
||||
data-testid="dashboard-description-input"
|
||||
/>
|
||||
</gl-form-group>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<gl-button
|
||||
v-if="showEditDashboardButton"
|
||||
icon="pencil"
|
||||
class="gl-mr-2"
|
||||
data-testid="dashboard-edit-btn"
|
||||
@click="startEdit"
|
||||
>{{ s__('Analytics|Edit') }}</gl-button
|
||||
>
|
||||
</section>
|
||||
<div class="-gl-mx-3">
|
||||
<div class="gl-flex">
|
||||
<div class="gl-flex gl-grow gl-flex-col">
|
||||
<section
|
||||
v-if="showFilters"
|
||||
data-testid="dashboard-filters"
|
||||
class="gl-flex gl-flex-col gl-gap-5 gl-px-3 gl-pb-3 gl-pt-4 md:gl-flex-row"
|
||||
>
|
||||
<date-range-filter
|
||||
v-if="showDateRangeFilter"
|
||||
:default-option="filters.dateRangeOption"
|
||||
:start-date="filters.startDate"
|
||||
:end-date="filters.endDate"
|
||||
:date-range-limit="dateRangeLimit"
|
||||
@change="setDateRangeFilter"
|
||||
/>
|
||||
<anon-users-filter
|
||||
v-if="showAnonUsersFilter"
|
||||
:value="filters.filterAnonUsers"
|
||||
@change="setAnonymousUsersFilter"
|
||||
/>
|
||||
</section>
|
||||
<url-sync
|
||||
v-if="syncUrlFilters"
|
||||
:query="queryParams"
|
||||
:history-update-method="$options.HISTORY_REPLACE_UPDATE_METHOD"
|
||||
/>
|
||||
<button
|
||||
v-if="showEditControls"
|
||||
class="card upload-dropzone-card upload-dropzone-border gl-m-3 gl-flex gl-items-center gl-px-5 gl-py-3"
|
||||
data-testid="add-visualization-button"
|
||||
@click="toggleVisualizationDrawer"
|
||||
>
|
||||
<div class="gl-flex gl-items-center gl-font-bold gl-text-gray-700">
|
||||
<div
|
||||
class="gl-mr-3 gl-inline-flex gl-h-7 gl-w-7 gl-items-center gl-justify-center gl-rounded-full gl-bg-gray-100"
|
||||
>
|
||||
<gl-icon name="plus" />
|
||||
</div>
|
||||
{{ s__('Analytics|Add visualization') }}
|
||||
</div>
|
||||
</button>
|
||||
<slot name="alert"></slot>
|
||||
<gridstack-wrapper v-model="dashboard" :editing="editing">
|
||||
<template #panel="{ panel }">
|
||||
<slot
|
||||
name="panel"
|
||||
v-bind="{ panel, filters, editing, deletePanel: () => deletePanel(panel) }"
|
||||
></slot>
|
||||
</template>
|
||||
</gridstack-wrapper>
|
||||
|
||||
<available-visualizations-drawer
|
||||
:visualizations="availableVisualizations.visualizations"
|
||||
:loading="availableVisualizations.loading"
|
||||
:has-error="availableVisualizations.hasError"
|
||||
:open="visualizationDrawerOpen"
|
||||
@select="addPanels"
|
||||
@close="closeVisualizationDrawer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="editing">
|
||||
<gl-button
|
||||
:loading="isSaving"
|
||||
class="gl-my-4 gl-mr-2"
|
||||
category="primary"
|
||||
variant="confirm"
|
||||
data-testid="dashboard-save-btn"
|
||||
@click="saveEdit"
|
||||
>{{ s__('Analytics|Save your dashboard') }}</gl-button
|
||||
>
|
||||
<gl-button category="secondary" data-testid="dashboard-cancel-edit-btn" @click="cancelEdit">{{
|
||||
s__('Analytics|Cancel')
|
||||
}}</gl-button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
<script>
|
||||
import { GlAlert, GlButton, GlLoadingIcon, GlDrawer, GlFormCheckbox } from '@gitlab/ui';
|
||||
import { humanize } from '~/lib/utils/text_utility';
|
||||
import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
|
||||
import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
|
||||
import { s__ } from '~/locale';
|
||||
import { toggleArrayItem } from '~/lib/utils/array_utility';
|
||||
import { getVisualizationCategory } from '../utils';
|
||||
import { CATEGORY_SINGLE_STATS, CATEGORY_CHARTS, CATEGORY_TABLES } from '../constants';
|
||||
|
||||
export default {
|
||||
name: 'AvailableVisualizatiosnDrawer',
|
||||
components: {
|
||||
GlAlert,
|
||||
GlButton,
|
||||
GlLoadingIcon,
|
||||
GlDrawer,
|
||||
GlFormCheckbox,
|
||||
},
|
||||
props: {
|
||||
visualizations: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
hasError: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
open: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selected: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
getDrawerHeaderHeight() {
|
||||
// avoid calculating this in advance because it causes layout thrashing
|
||||
// https://gitlab.com/gitlab-org/gitlab/-/issues/331172#note_1269378396
|
||||
if (!this.open) return '0';
|
||||
return getContentWrapperHeight();
|
||||
},
|
||||
addButtonDisabled() {
|
||||
return this.selected.length < 1;
|
||||
},
|
||||
categorizedVisualizations() {
|
||||
return this.visualizations.reduce(
|
||||
(categories, visualization) => {
|
||||
const category = getVisualizationCategory(visualization);
|
||||
categories[category].visualizations.push(visualization);
|
||||
return categories;
|
||||
},
|
||||
{
|
||||
[CATEGORY_SINGLE_STATS]: {
|
||||
title: s__('Analytics|Single stats'),
|
||||
visualizations: [],
|
||||
},
|
||||
[CATEGORY_TABLES]: {
|
||||
title: s__('Analytics|Tables'),
|
||||
visualizations: [],
|
||||
},
|
||||
[CATEGORY_CHARTS]: {
|
||||
title: s__('Analytics|Charts'),
|
||||
visualizations: [],
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
filteredCategorizedVisualizations() {
|
||||
return Object.fromEntries(
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
Object.entries(this.categorizedVisualizations).filter(([_, category]) => {
|
||||
return category.visualizations.length > 0;
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
open: {
|
||||
immediate: true,
|
||||
handler(opened) {
|
||||
if (opened) {
|
||||
this.focusFirstCheckbox();
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async focusFirstCheckbox() {
|
||||
if (Object.keys(this.filteredCategorizedVisualizations).length < 1) return;
|
||||
|
||||
// Wait for checkboxes to render
|
||||
await this.$nextTick();
|
||||
|
||||
this.$refs.checkbox[0].$el.querySelector('input').focus();
|
||||
},
|
||||
clickedListItem(visualization, event) {
|
||||
// Only toggle the selected value if the list item itself was clicked
|
||||
// to prevent checkbox clicks from double toggling
|
||||
if (event?.target?.tagName !== 'LI') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.selected = toggleArrayItem(this.selected, visualization);
|
||||
},
|
||||
getVisualizationTitle(slug) {
|
||||
return humanize(slug);
|
||||
},
|
||||
onAddClicked() {
|
||||
this.$emit('select', this.selected);
|
||||
|
||||
this.selected = [];
|
||||
},
|
||||
},
|
||||
DRAWER_Z_INDEX,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<gl-drawer
|
||||
:open="open"
|
||||
:header-height="getDrawerHeaderHeight"
|
||||
:z-index="$options.DRAWER_Z_INDEX"
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<template #title>
|
||||
<h3 class="gl-m-0">{{ s__('Analytics|Add visualizations') }}</h3>
|
||||
</template>
|
||||
|
||||
<gl-loading-icon v-if="loading" size="md" class="gl-mb-4" />
|
||||
|
||||
<gl-alert
|
||||
v-else-if="hasError"
|
||||
variant="danger"
|
||||
:show-icon="false"
|
||||
:dismissible="false"
|
||||
class="gl-m-4"
|
||||
>
|
||||
{{
|
||||
s__(
|
||||
'Analytics|Something went wrong while loading available visualizations. Refresh the page to try again.',
|
||||
)
|
||||
}}
|
||||
</gl-alert>
|
||||
|
||||
<div v-else>
|
||||
<div v-for="(category, key) in filteredCategorizedVisualizations" :key="key">
|
||||
<div data-testid="category-title" class="gl-mb-4 gl-font-bold gl-text-gray-900">
|
||||
{{ category.title }}
|
||||
</div>
|
||||
<ul class="gl-mb-6 gl-list-none gl-p-0">
|
||||
<li
|
||||
v-for="(visualization, index) in category.visualizations"
|
||||
:key="index"
|
||||
:data-testid="`list-item-${visualization.slug}`"
|
||||
class="gl-border gl-mb-4 gl-flex gl-cursor-pointer gl-rounded-base gl-px-4 gl-pb-2 gl-pt-4"
|
||||
@click="clickedListItem(visualization, $event)"
|
||||
>
|
||||
<gl-form-checkbox ref="checkbox" v-model="selected" :value="visualization">
|
||||
{{ getVisualizationTitle(visualization.slug) }}
|
||||
</gl-form-checkbox>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<gl-button
|
||||
:disabled="addButtonDisabled"
|
||||
data-testid="add-button"
|
||||
block
|
||||
variant="confirm"
|
||||
category="secondary"
|
||||
@click="onAddClicked"
|
||||
>{{ s__('Analytics|Add to dashboard') }}</gl-button
|
||||
>
|
||||
</template>
|
||||
</gl-drawer>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<script>
|
||||
import { GlToggle, GlIcon, GlTooltipDirective } from '@gitlab/ui';
|
||||
|
||||
export default {
|
||||
name: 'AnonUsersFilter',
|
||||
components: {
|
||||
GlToggle,
|
||||
GlIcon,
|
||||
},
|
||||
directives: {
|
||||
GlTooltip: GlTooltipDirective,
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gl-flex gl-w-full gl-flex-row gl-gap-3 sm:gl-w-auto">
|
||||
<gl-toggle
|
||||
:value="value"
|
||||
:label="s__('Analytics|Exclude anonymous users')"
|
||||
label-position="left"
|
||||
@change="$emit('change', $event)"
|
||||
/>
|
||||
<gl-icon
|
||||
v-gl-tooltip
|
||||
:title="s__('Analytics|View metrics only for users who have consented to activity tracking.')"
|
||||
name="information-o"
|
||||
class="gl-self-center gl-text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { __, sprintf } from '~/locale';
|
||||
import { getCurrentUtcDate, getDateInPast } from '~/lib/utils/datetime_utility';
|
||||
|
||||
export const TODAY = getCurrentUtcDate();
|
||||
export const SEVEN_DAYS_AGO = getDateInPast(TODAY, 7);
|
||||
|
||||
export const CUSTOM_DATE_RANGE_KEY = 'custom';
|
||||
|
||||
/**
|
||||
* The default options to display in the date_range_filter.
|
||||
*
|
||||
* Each options consists of:
|
||||
*
|
||||
* key - The key used to select the option and sync with the URL
|
||||
* text - Text to display in the dropdown item
|
||||
* startDate - Optional, the start date to set
|
||||
* endDate - Optional, the end date to set
|
||||
* showDateRangePicker - Optional, show the date range picker component and uses
|
||||
* it to set the date.
|
||||
*/
|
||||
export const DATE_RANGE_OPTIONS = [
|
||||
{
|
||||
key: 'last_30_days',
|
||||
text: sprintf(__('Last %{days} days'), { days: 30 }),
|
||||
startDate: getDateInPast(TODAY, 30),
|
||||
endDate: TODAY,
|
||||
},
|
||||
{
|
||||
key: 'last_7_days',
|
||||
text: sprintf(__('Last %{days} days'), { days: 7 }),
|
||||
startDate: SEVEN_DAYS_AGO,
|
||||
endDate: TODAY,
|
||||
},
|
||||
{
|
||||
key: 'today',
|
||||
text: __('Today'),
|
||||
startDate: TODAY,
|
||||
endDate: TODAY,
|
||||
},
|
||||
{
|
||||
key: CUSTOM_DATE_RANGE_KEY,
|
||||
text: __('Custom range'),
|
||||
showDateRangePicker: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_SELECTED_OPTION_INDEX = 1;
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
<script>
|
||||
import { GlCollapsibleListbox, GlDaterangePicker, GlIcon, GlTooltipDirective } from '@gitlab/ui';
|
||||
import { n__ } from '~/locale';
|
||||
import { dateRangeOptionToFilter, getDateRangeOption } from '../utils';
|
||||
import { TODAY, DATE_RANGE_OPTIONS, DEFAULT_SELECTED_OPTION_INDEX } from './constants';
|
||||
|
||||
export default {
|
||||
name: 'DateRangeFilter',
|
||||
components: {
|
||||
GlCollapsibleListbox,
|
||||
GlDaterangePicker,
|
||||
GlIcon,
|
||||
},
|
||||
directives: {
|
||||
GlTooltip: GlTooltipDirective,
|
||||
},
|
||||
props: {
|
||||
defaultOption: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: DATE_RANGE_OPTIONS[DEFAULT_SELECTED_OPTION_INDEX].key,
|
||||
},
|
||||
startDate: {
|
||||
type: Date,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
endDate: {
|
||||
type: Date,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
dateRangeLimit: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedItem: getDateRangeOption(this.defaultOption),
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
dateRange: {
|
||||
get() {
|
||||
return { startDate: this.startDate, endDate: this.endDate };
|
||||
},
|
||||
set({ startDate, endDate }) {
|
||||
this.$emit(
|
||||
'change',
|
||||
dateRangeOptionToFilter({
|
||||
...this.selectedItem,
|
||||
startDate,
|
||||
endDate,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
dateRangeTooltip() {
|
||||
if (this.dateRangeLimit) {
|
||||
return n__(
|
||||
'Date range limited to %d day',
|
||||
'Date range limited to %d days',
|
||||
this.dateRangeLimit,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
dropdownItems() {
|
||||
return this.$options.DATE_RANGE_OPTIONS.map((item) => {
|
||||
return { text: item.text, value: item.key };
|
||||
});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
selectItem(key) {
|
||||
const item = this.$options.DATE_RANGE_OPTIONS.find((option) => option.key === key);
|
||||
this.selectedItem = item;
|
||||
|
||||
const { startDate, endDate, showDateRangePicker = false } = item;
|
||||
if (!showDateRangePicker && startDate && endDate) {
|
||||
this.dateRange = { startDate, endDate };
|
||||
}
|
||||
|
||||
this.showDateRangePicker = showDateRangePicker;
|
||||
},
|
||||
},
|
||||
DATE_RANGE_OPTIONS,
|
||||
TODAY,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="gl-flex gl-w-full gl-gap-3 sm:gl-w-auto sm:gl-flex-row"
|
||||
:class="{ 'gl-flex-col': selectedItem.showDateRangePicker }"
|
||||
>
|
||||
<gl-collapsible-listbox
|
||||
class="gl-w-full sm:gl-w-auto"
|
||||
:items="dropdownItems"
|
||||
:selected="selectedItem.key"
|
||||
@select="selectItem($event)"
|
||||
/>
|
||||
<div class="gl-flex gl-gap-3">
|
||||
<gl-daterange-picker
|
||||
v-if="selectedItem.showDateRangePicker"
|
||||
v-model="dateRange"
|
||||
:default-start-date="dateRange.startDate"
|
||||
:default-end-date="dateRange.endDate"
|
||||
:default-max-date="$options.TODAY"
|
||||
:max-date-range="dateRangeLimit"
|
||||
:to-label="__('To')"
|
||||
:from-label="__('From')"
|
||||
:tooltip="dateRangeTooltip"
|
||||
same-day-selection
|
||||
/>
|
||||
<gl-icon
|
||||
v-gl-tooltip
|
||||
:title="s__('Analytics|Dates and times are displayed in the UTC timezone')"
|
||||
name="information-o"
|
||||
class="gl-mb-3 gl-min-w-5 gl-self-end gl-text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1 @@
|
|||
export const TYPENAME_ANALYTICS_DASHBOARD_PANEL = 'CustomizableDashboardPanel';
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
fragment CustomizableDashboardFragment on CustomizableDashboardConnection {
|
||||
nodes {
|
||||
slug
|
||||
title
|
||||
description
|
||||
userDefined
|
||||
status
|
||||
errors
|
||||
panels {
|
||||
nodes {
|
||||
title
|
||||
gridAttributes
|
||||
queryOverrides
|
||||
visualization {
|
||||
slug
|
||||
type
|
||||
options
|
||||
data
|
||||
errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
fragment CustomizableDashboardsFragment on CustomizableDashboardConnection {
|
||||
nodes {
|
||||
slug
|
||||
title
|
||||
description
|
||||
userDefined
|
||||
status
|
||||
errors
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
#import "../fragments/customizable_dashboards.fragment.graphql"
|
||||
|
||||
query getAllCusomizableDashboards(
|
||||
$fullPath: ID!
|
||||
$isGroup: Boolean = false
|
||||
$isProject: Boolean = false
|
||||
) {
|
||||
project(fullPath: $fullPath) @include(if: $isProject) {
|
||||
id
|
||||
customizableDashboards {
|
||||
...CustomizableDashboardsFragment
|
||||
}
|
||||
}
|
||||
group(fullPath: $fullPath) @include(if: $isGroup) {
|
||||
id
|
||||
customizableDashboards {
|
||||
...CustomizableDashboardsFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
#import "../fragments/customizable_dashboard.fragment.graphql"
|
||||
|
||||
query getCustomizableDashboard(
|
||||
$fullPath: ID!
|
||||
$slug: String
|
||||
$isGroup: Boolean = false
|
||||
$isProject: Boolean = false
|
||||
) {
|
||||
project(fullPath: $fullPath) @include(if: $isProject) {
|
||||
id
|
||||
customizableDashboards(slug: $slug, category: ANALYTICS) {
|
||||
...CustomizableDashboardFragment
|
||||
}
|
||||
}
|
||||
group(fullPath: $fullPath) @include(if: $isGroup) {
|
||||
id
|
||||
customizableDashboards(slug: $slug, category: ANALYTICS) {
|
||||
...CustomizableDashboardFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
<script>
|
||||
import { GridStack } from 'gridstack';
|
||||
import * as Sentry from '~/sentry/sentry_browser_wrapper';
|
||||
import { cloneWithoutReferences } from '~/lib/utils/common_utils';
|
||||
import { loadCSSFile } from '~/lib/utils/css_utils';
|
||||
import { GRIDSTACK_BASE_CONFIG, CURSOR_GRABBING_CLASS } from './constants';
|
||||
import { parsePanelToGridItem } from './utils';
|
||||
|
||||
export default {
|
||||
name: 'GridstackWrapper',
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
editing: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
grid: undefined,
|
||||
cssLoaded: false,
|
||||
mounted: false,
|
||||
gridPanels: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
mountedWithCss() {
|
||||
return this.cssLoaded && this.mounted;
|
||||
},
|
||||
gridConfig() {
|
||||
return this.value.panels.map(parsePanelToGridItem);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
mountedWithCss(mountedWithCss) {
|
||||
if (mountedWithCss) {
|
||||
this.initGridStack();
|
||||
}
|
||||
},
|
||||
editing(value) {
|
||||
this.grid?.setStatic(!value);
|
||||
},
|
||||
gridConfig: {
|
||||
handler(config) {
|
||||
this.grid?.load(config);
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.mounted = true;
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.mounted = false;
|
||||
const removeDom = Boolean(this.$el.parentElement);
|
||||
this.grid?.destroy(removeDom);
|
||||
},
|
||||
async created() {
|
||||
try {
|
||||
await loadCSSFile(gon.gridstack_css_path);
|
||||
this.cssLoaded = true;
|
||||
} catch (e) {
|
||||
Sentry.captureException(e);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async mountGridComponents(panels, options = { scollIntoView: false }) {
|
||||
// Ensure new panels are always rendered first
|
||||
await this.$nextTick();
|
||||
|
||||
panels.forEach((panel) => {
|
||||
const wrapper = this.$refs.panelWrappers.find((w) => w.id === panel.id);
|
||||
const widgetContentEl = panel.el.querySelector('.grid-stack-item-content');
|
||||
|
||||
widgetContentEl.appendChild(wrapper);
|
||||
});
|
||||
|
||||
if (options.scrollIntoView) {
|
||||
const mostRecent = panels[panels.length - 1];
|
||||
mostRecent.el.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
},
|
||||
getGridItemForElement(el) {
|
||||
return this.gridConfig.find((item) => item.id === el.getAttribute('gs-id'));
|
||||
},
|
||||
initGridPanelSlots(gridElements) {
|
||||
if (!gridElements) return;
|
||||
|
||||
this.gridPanels = gridElements.map((el) => ({
|
||||
...this.getGridItemForElement(el),
|
||||
el,
|
||||
}));
|
||||
|
||||
this.mountGridComponents(this.gridPanels);
|
||||
},
|
||||
initGridStack() {
|
||||
this.grid = GridStack.init({
|
||||
...GRIDSTACK_BASE_CONFIG,
|
||||
staticGrid: !this.editing,
|
||||
}).load(this.gridConfig);
|
||||
|
||||
// Sync Vue components array with gridstack items
|
||||
this.initGridPanelSlots(this.grid.getGridItems());
|
||||
|
||||
this.grid.on('dragstart', () => {
|
||||
this.$el.classList.add(CURSOR_GRABBING_CLASS);
|
||||
});
|
||||
this.grid.on('dragstop', () => {
|
||||
this.$el.classList.remove(CURSOR_GRABBING_CLASS);
|
||||
});
|
||||
this.grid.on('change', (_, items) => {
|
||||
if (!items) return;
|
||||
|
||||
this.emitLayoutChanges(items);
|
||||
});
|
||||
this.grid.on('added', (_, items) => {
|
||||
this.addGridPanels(items);
|
||||
});
|
||||
this.grid.on('removed', (_, items) => {
|
||||
this.removeGridPanels(items);
|
||||
});
|
||||
},
|
||||
convertToGridAttributes(gridStackItem) {
|
||||
return {
|
||||
yPos: gridStackItem.y,
|
||||
xPos: gridStackItem.x,
|
||||
width: gridStackItem.w,
|
||||
height: gridStackItem.h,
|
||||
};
|
||||
},
|
||||
removeGridPanels(items) {
|
||||
items.forEach((item) => {
|
||||
const index = this.gridPanels.findIndex((c) => c.id === item.id);
|
||||
this.gridPanels.splice(index, 1);
|
||||
// Finally remove the gridstack element
|
||||
item.el.remove();
|
||||
});
|
||||
},
|
||||
addGridPanels(items) {
|
||||
const newPanels = items.map(({ grid, ...rest }) => ({ ...rest }));
|
||||
this.gridPanels.push(...newPanels);
|
||||
|
||||
this.mountGridComponents(newPanels, { scollIntoView: true });
|
||||
},
|
||||
emitLayoutChanges(items) {
|
||||
const newValue = cloneWithoutReferences(this.value);
|
||||
items.forEach((item) => {
|
||||
const panel = newValue.panels.find((p) => p.id === item.id);
|
||||
panel.gridAttributes = {
|
||||
...panel.gridAttributes,
|
||||
...this.convertToGridAttributes(item),
|
||||
};
|
||||
});
|
||||
this.$emit('input', newValue);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid-stack" data-testid="gridstack-grid">
|
||||
<div
|
||||
v-for="panel in gridPanels"
|
||||
:id="panel.id"
|
||||
ref="panelWrappers"
|
||||
:key="panel.id"
|
||||
class="gl-h-full"
|
||||
:class="{ 'gl-cursor-grab': editing }"
|
||||
data-testid="grid-stack-panel"
|
||||
>
|
||||
<slot name="panel" v-bind="{ panel: panel.props }"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import { s__, __ } from '~/locale';
|
||||
import { VARIANT_DANGER, VARIANT_WARNING, VARIANT_INFO } from '~/alert';
|
||||
import PanelsBase from './panels_base.vue';
|
||||
|
||||
export default {
|
||||
component: PanelsBase,
|
||||
title: '~/vue_shared/components/panels_base',
|
||||
};
|
||||
|
||||
const Template = (args, { argTypes }) => ({
|
||||
components: { PanelsBase },
|
||||
props: Object.keys(argTypes),
|
||||
template: `
|
||||
<panels-base v-bind="$props" style="min-height: 7rem;">
|
||||
<template #body>
|
||||
<p><code>#body</code> slot content</p>
|
||||
</template>
|
||||
<template #alert-popover>
|
||||
<div><code>#alert-popover</code> slot content</div>
|
||||
</template>
|
||||
</panels-base>
|
||||
`,
|
||||
});
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {
|
||||
title: s__('ProductAnalytics|Audience'),
|
||||
tooltip: null,
|
||||
loading: false,
|
||||
showAlertState: false,
|
||||
alertPopoverTitle: '',
|
||||
actions: [],
|
||||
editing: false,
|
||||
};
|
||||
|
||||
export const Loading = Template.bind({});
|
||||
Loading.args = {
|
||||
...Default.args,
|
||||
loading: true,
|
||||
};
|
||||
|
||||
export const Error = Template.bind({});
|
||||
Error.args = {
|
||||
...Default.args,
|
||||
alertPopoverTitle: __('An error has occurred'),
|
||||
showAlertState: true,
|
||||
alertVariant: VARIANT_DANGER,
|
||||
};
|
||||
|
||||
export const Warning = Template.bind({});
|
||||
Warning.args = {
|
||||
...Default.args,
|
||||
alertPopoverTitle: __('This is really just a warning'),
|
||||
showAlertState: true,
|
||||
alertVariant: VARIANT_WARNING,
|
||||
};
|
||||
|
||||
export const Information = Template.bind({});
|
||||
Information.args = {
|
||||
...Default.args,
|
||||
alertPopoverTitle: __('Some friendly information'),
|
||||
showAlertState: true,
|
||||
alertVariant: VARIANT_INFO,
|
||||
};
|
||||
|
||||
export const Editing = Template.bind({});
|
||||
Editing.args = {
|
||||
...Default.args,
|
||||
editing: true,
|
||||
actions: [
|
||||
{
|
||||
text: __('Delete'),
|
||||
icon: 'remove',
|
||||
action: () => {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const WithInformationalTooltip = Template.bind({});
|
||||
WithInformationalTooltip.args = {
|
||||
...Default.args,
|
||||
tooltip: {
|
||||
description: __('This is some information. %{linkStart}Learn more%{linkEnd}.'),
|
||||
descriptionLink: '#',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLoadingDelayed = Template.bind({});
|
||||
WithLoadingDelayed.args = {
|
||||
...Default.args,
|
||||
loading: true,
|
||||
loadingDelayed: true,
|
||||
};
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
<script>
|
||||
import {
|
||||
GlDisclosureDropdown,
|
||||
GlIcon,
|
||||
GlLoadingIcon,
|
||||
GlPopover,
|
||||
GlSprintf,
|
||||
GlLink,
|
||||
} from '@gitlab/ui';
|
||||
import { alertVariantIconMap } from '@gitlab/ui/src/utils/constants';
|
||||
import uniqueId from 'lodash/uniqueId';
|
||||
import { isObject } from 'lodash';
|
||||
import { VARIANT_DANGER, VARIANT_WARNING, VARIANT_INFO } from '~/alert';
|
||||
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
|
||||
import { PANEL_POPOVER_DELAY } from './constants';
|
||||
|
||||
export default {
|
||||
name: 'PanelsBase',
|
||||
components: {
|
||||
GlDisclosureDropdown,
|
||||
GlLoadingIcon,
|
||||
GlIcon,
|
||||
GlPopover,
|
||||
TooltipOnTruncate,
|
||||
GlSprintf,
|
||||
GlLink,
|
||||
},
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
tooltip: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
loadingDelayed: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
showAlertState: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
alertVariant: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: VARIANT_DANGER,
|
||||
validator: (variant) => [VARIANT_WARNING, VARIANT_DANGER, VARIANT_INFO].includes(variant),
|
||||
},
|
||||
alertPopoverTitle: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
actions: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => [],
|
||||
validator: (actions) => actions.every((a) => isObject(a)),
|
||||
},
|
||||
editing: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
popoverId: uniqueId('panel-alert-popover-'),
|
||||
titleTooltipId: uniqueId('title-tooltip-id-'),
|
||||
dropdownOpen: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
alertClasses() {
|
||||
const borderColor = this.showAlertState
|
||||
? this.$options.alertBorderColorMap[this.alertVariant]
|
||||
: '';
|
||||
|
||||
return `gl-border-t-2 gl-border-t-solid ${borderColor}`;
|
||||
},
|
||||
alertIconClasses() {
|
||||
return this.$options.alertIconClassMap[this.alertVariant];
|
||||
},
|
||||
alertIcon() {
|
||||
return this.$options.alertVariantIconMap[this.alertVariant] ?? alertVariantIconMap.danger;
|
||||
},
|
||||
showAlertPopover() {
|
||||
return this.showAlertState && !this.dropdownOpen;
|
||||
},
|
||||
},
|
||||
PANEL_POPOVER_DELAY,
|
||||
alertVariantIconMap,
|
||||
alertBorderColorMap: {
|
||||
[VARIANT_DANGER]: 'gl-border-t-red-500',
|
||||
[VARIANT_WARNING]: 'gl-border-t-orange-500',
|
||||
[VARIANT_INFO]: 'gl-border-t-blue-500',
|
||||
},
|
||||
alertIconClassMap: {
|
||||
[VARIANT_DANGER]: 'gl-text-red-500',
|
||||
[VARIANT_WARNING]: 'gl-text-orange-500',
|
||||
[VARIANT_INFO]: 'gl-text-blue-500',
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:id="popoverId"
|
||||
class="grid-stack-item-content gl-border gl-h-full !gl-overflow-visible gl-rounded-base gl-bg-white gl-p-4"
|
||||
:class="alertClasses"
|
||||
>
|
||||
<div class="gl-flex gl-h-full gl-flex-col">
|
||||
<div class="gl-flex gl-items-start gl-justify-between" data-testid="panel-title">
|
||||
<tooltip-on-truncate
|
||||
v-if="title"
|
||||
:title="title"
|
||||
placement="top"
|
||||
boundary="viewport"
|
||||
class="gl-truncate gl-pb-3"
|
||||
>
|
||||
<gl-icon
|
||||
v-if="showAlertState"
|
||||
class="gl-mr-1"
|
||||
:class="alertIconClasses"
|
||||
:name="alertIcon"
|
||||
data-testid="panel-title-alert-icon"
|
||||
/>
|
||||
<strong class="gl-text-gray-700">{{ title }}</strong>
|
||||
<template v-if="tooltip && tooltip.description">
|
||||
<gl-icon
|
||||
:id="titleTooltipId"
|
||||
data-testid="panel-title-tooltip-icon"
|
||||
name="information-o"
|
||||
variant="info"
|
||||
/>
|
||||
<gl-popover
|
||||
data-testid="panel-title-popover"
|
||||
boundary="viewport"
|
||||
:target="titleTooltipId"
|
||||
>
|
||||
<gl-sprintf v-if="tooltip.descriptionLink" :message="tooltip.description">
|
||||
<template #link="{ content }">
|
||||
<gl-link :href="tooltip.descriptionLink" class="gl-text-sm">{{
|
||||
content
|
||||
}}</gl-link>
|
||||
</template>
|
||||
</gl-sprintf>
|
||||
<template v-else>
|
||||
{{ tooltip.description }}
|
||||
</template>
|
||||
</gl-popover>
|
||||
</template>
|
||||
</tooltip-on-truncate>
|
||||
|
||||
<gl-disclosure-dropdown
|
||||
v-if="editing"
|
||||
:items="actions"
|
||||
icon="ellipsis_v"
|
||||
:toggle-text="__('Actions')"
|
||||
text-sr-only
|
||||
no-caret
|
||||
placement="bottom-end"
|
||||
fluid-width
|
||||
toggle-class="gl-ml-1"
|
||||
category="tertiary"
|
||||
positioning-strategy="fixed"
|
||||
@shown="dropdownOpen = true"
|
||||
@hidden="dropdownOpen = false"
|
||||
>
|
||||
<template #list-item="{ item }">
|
||||
<span> <gl-icon :name="item.icon" /> {{ item.text }}</span>
|
||||
</template>
|
||||
</gl-disclosure-dropdown>
|
||||
</div>
|
||||
<div
|
||||
class="gl-grow gl-overflow-y-auto gl-overflow-x-hidden"
|
||||
:class="{ 'gl-flex gl-flex-wrap gl-content-center gl-text-center': loading }"
|
||||
>
|
||||
<template v-if="loading">
|
||||
<gl-loading-icon size="lg" class="gl-w-full" />
|
||||
<div
|
||||
v-if="loadingDelayed"
|
||||
class="gl-w-full gl-text-subtle"
|
||||
data-testId="panel-loading-delayed-indicator"
|
||||
>
|
||||
{{ __('Still loading...') }}
|
||||
</div>
|
||||
</template>
|
||||
<!-- @slot The panel body to display when not loading. -->
|
||||
<slot v-else name="body"></slot>
|
||||
</div>
|
||||
|
||||
<gl-popover
|
||||
v-if="showAlertPopover"
|
||||
triggers="hover focus"
|
||||
:title="alertPopoverTitle"
|
||||
:show-close-button="false"
|
||||
placement="top"
|
||||
:css-classes="['gl-max-w-1/2']"
|
||||
:target="popoverId"
|
||||
:delay="$options.PANEL_POPOVER_DELAY"
|
||||
boundary="viewport"
|
||||
>
|
||||
<!-- @slot The panel error popover body to display when showAlertState is true. -->
|
||||
<slot name="alert-popover"></slot>
|
||||
</gl-popover>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
import produce from 'immer';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import uniqueId from 'lodash/uniqueId';
|
||||
import { queryToObject } from '~/lib/utils/url_utility';
|
||||
import { formatDate, newDate } from '~/lib/utils/datetime_utility';
|
||||
import { ISO_SHORT_FORMAT } from '~/vue_shared/constants';
|
||||
import { humanize } from '~/lib/utils/text_utility';
|
||||
import {
|
||||
convertObjectPropsToCamelCase,
|
||||
convertObjectPropsToSnakeCase,
|
||||
parseBoolean,
|
||||
cloneWithoutReferences,
|
||||
} from '~/lib/utils/common_utils';
|
||||
import getAllCustomizableDashboardsQuery from './graphql/queries/get_all_customizable_dashboards.query.graphql';
|
||||
import getCustomizableDashboardQuery from './graphql/queries/get_customizable_dashboard.query.graphql';
|
||||
import { TYPENAME_ANALYTICS_DASHBOARD_PANEL } from './graphql/constants';
|
||||
import {
|
||||
DASHBOARD_SCHEMA_VERSION,
|
||||
VISUALIZATION_TYPE_DATA_TABLE,
|
||||
VISUALIZATION_TYPE_SINGLE_STAT,
|
||||
CATEGORY_SINGLE_STATS,
|
||||
CATEGORY_CHARTS,
|
||||
CATEGORY_TABLES,
|
||||
} from './constants';
|
||||
import {
|
||||
DATE_RANGE_OPTIONS,
|
||||
CUSTOM_DATE_RANGE_KEY,
|
||||
DEFAULT_SELECTED_OPTION_INDEX,
|
||||
} from './filters/constants';
|
||||
|
||||
const isCustomOption = (option) => option && option === CUSTOM_DATE_RANGE_KEY;
|
||||
|
||||
export const getDateRangeOption = (optionKey) =>
|
||||
DATE_RANGE_OPTIONS.find(({ key }) => key === optionKey);
|
||||
|
||||
export const dateRangeOptionToFilter = ({ startDate, endDate, key }) => ({
|
||||
startDate,
|
||||
endDate,
|
||||
dateRangeOption: key,
|
||||
});
|
||||
|
||||
const DEFAULT_FILTER = dateRangeOptionToFilter(DATE_RANGE_OPTIONS[DEFAULT_SELECTED_OPTION_INDEX]);
|
||||
|
||||
export const buildDefaultDashboardFilters = (queryString) => {
|
||||
const {
|
||||
dateRangeOption: optionKey,
|
||||
startDate,
|
||||
endDate,
|
||||
filterAnonUsers,
|
||||
} = convertObjectPropsToCamelCase(queryToObject(queryString, { gatherArrays: true }));
|
||||
|
||||
const customDateRange = isCustomOption(optionKey);
|
||||
|
||||
return {
|
||||
...DEFAULT_FILTER,
|
||||
// Override default filter with user defined option
|
||||
...(optionKey && dateRangeOptionToFilter(getDateRangeOption(optionKey))),
|
||||
// Override date range when selected option is custom date range
|
||||
...(customDateRange && { startDate: newDate(startDate) }),
|
||||
...(customDateRange && { endDate: newDate(endDate) }),
|
||||
filterAnonUsers: parseBoolean(filterAnonUsers),
|
||||
};
|
||||
};
|
||||
|
||||
export const filtersToQueryParams = ({ dateRangeOption, startDate, endDate, filterAnonUsers }) => {
|
||||
const customDateRange = isCustomOption(dateRangeOption);
|
||||
|
||||
return convertObjectPropsToSnakeCase({
|
||||
dateRangeOption,
|
||||
// Clear the date range unless the custom date range is selected
|
||||
startDate: customDateRange ? formatDate(startDate, ISO_SHORT_FORMAT) : null,
|
||||
endDate: customDateRange ? formatDate(endDate, ISO_SHORT_FORMAT) : null,
|
||||
// Clear the anon users filter unless truthy
|
||||
filterAnonUsers: filterAnonUsers || null,
|
||||
});
|
||||
};
|
||||
|
||||
export const isEmptyPanelData = (visualizationType, data) => {
|
||||
if (visualizationType === 'SingleStat') {
|
||||
// SingleStat visualizations currently do not show an empty state, and instead show a default "0" value
|
||||
// This will be revisited: https://gitlab.com/gitlab-org/gitlab/-/issues/398792
|
||||
return false;
|
||||
}
|
||||
return isEmpty(data);
|
||||
};
|
||||
|
||||
/**
|
||||
* Validator for the availableVisualizations property
|
||||
*/
|
||||
export const availableVisualizationsValidator = ({ loading, hasError, visualizations }) => {
|
||||
return (
|
||||
typeof loading === 'boolean' && typeof hasError === 'boolean' && Array.isArray(visualizations)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the category key for visualizations by their type. Default is "charts".
|
||||
*/
|
||||
export const getVisualizationCategory = (visualization) => {
|
||||
if (visualization.type === VISUALIZATION_TYPE_SINGLE_STAT) {
|
||||
return CATEGORY_SINGLE_STATS;
|
||||
}
|
||||
if (visualization.type === VISUALIZATION_TYPE_DATA_TABLE) {
|
||||
return CATEGORY_TABLES;
|
||||
}
|
||||
return CATEGORY_CHARTS;
|
||||
};
|
||||
|
||||
export const getUniquePanelId = () => uniqueId('panel-');
|
||||
|
||||
/**
|
||||
* Maps a full hydrated dashboard (including GraphQL __typenames, and full visualization definitions) into a slimmed down version that complies with the dashboard schema definition
|
||||
*/
|
||||
export const getDashboardConfig = (hydratedDashboard) => {
|
||||
const { __typename: dashboardTypename, userDefined, slug, ...dashboardRest } = hydratedDashboard;
|
||||
return {
|
||||
...dashboardRest,
|
||||
version: DASHBOARD_SCHEMA_VERSION,
|
||||
panels: hydratedDashboard.panels.map((panel) => {
|
||||
const { __typename: panelTypename, id, ...panelRest } = panel;
|
||||
return {
|
||||
...panelRest,
|
||||
queryOverrides: panel.queryOverrides ?? {},
|
||||
visualization: panel.visualization.slug,
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates a dashboard detail in cache from getProductAnalyticsDashboard:{slug}
|
||||
*/
|
||||
const updateDashboardDetailsApolloCache = ({
|
||||
apolloClient,
|
||||
dashboard,
|
||||
slug,
|
||||
fullPath,
|
||||
isProject,
|
||||
isGroup,
|
||||
}) => {
|
||||
const getDashboardDetailsQuery = {
|
||||
query: getCustomizableDashboardQuery,
|
||||
variables: {
|
||||
fullPath,
|
||||
slug,
|
||||
isProject,
|
||||
isGroup,
|
||||
},
|
||||
};
|
||||
const sourceData = apolloClient.readQuery(getDashboardDetailsQuery);
|
||||
if (!sourceData) {
|
||||
// Dashboard details not yet in cache, must be a new dashboard, nothing to update
|
||||
return;
|
||||
}
|
||||
|
||||
const data = produce(sourceData, (draftState) => {
|
||||
const { nodes } = isProject
|
||||
? draftState.project.customizableDashboards
|
||||
: draftState.group.customizableDashboards;
|
||||
const updateIndex = nodes.findIndex((node) => node.slug === slug);
|
||||
|
||||
if (updateIndex < 0) return;
|
||||
|
||||
const updateNode = nodes[updateIndex];
|
||||
|
||||
nodes.splice(updateIndex, 1, {
|
||||
...updateNode,
|
||||
...dashboard,
|
||||
panels: {
|
||||
...updateNode.panels,
|
||||
nodes:
|
||||
dashboard.panels?.map((panel) => {
|
||||
const { id, ...panelRest } = panel;
|
||||
return { __typename: TYPENAME_ANALYTICS_DASHBOARD_PANEL, ...panelRest };
|
||||
}) || [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
apolloClient.writeQuery({
|
||||
...getDashboardDetailsQuery,
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds/updates a newly created dashboard to the dashboards list cache from getAllCustomizableDashboardsQuery
|
||||
*/
|
||||
const updateDashboardsListApolloCache = ({
|
||||
apolloClient,
|
||||
dashboardSlug,
|
||||
dashboard,
|
||||
fullPath,
|
||||
isProject,
|
||||
isGroup,
|
||||
}) => {
|
||||
const getDashboardListQuery = {
|
||||
query: getAllCustomizableDashboardsQuery,
|
||||
variables: {
|
||||
fullPath,
|
||||
isProject,
|
||||
isGroup,
|
||||
},
|
||||
};
|
||||
const sourceData = apolloClient.readQuery(getDashboardListQuery);
|
||||
if (!sourceData) {
|
||||
// Dashboard list not yet loaded in cache, nothing to update
|
||||
return;
|
||||
}
|
||||
|
||||
const data = produce(sourceData, (draftState) => {
|
||||
const { panels, ...dashboardWithoutPanels } = dashboard;
|
||||
const { nodes } = isProject
|
||||
? draftState.project.customizableDashboards
|
||||
: draftState.group.customizableDashboards;
|
||||
|
||||
const updateIndex = nodes.findIndex(({ slug }) => slug === dashboardSlug);
|
||||
|
||||
// Add new dashboard if it doesn't exist
|
||||
if (updateIndex < 0) {
|
||||
nodes.push(dashboardWithoutPanels);
|
||||
return;
|
||||
}
|
||||
|
||||
nodes.splice(updateIndex, 1, {
|
||||
...nodes[updateIndex],
|
||||
...dashboardWithoutPanels,
|
||||
});
|
||||
});
|
||||
|
||||
apolloClient.writeQuery({
|
||||
...getDashboardListQuery,
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
export const updateApolloCache = ({
|
||||
apolloClient,
|
||||
slug,
|
||||
dashboard,
|
||||
fullPath,
|
||||
isProject,
|
||||
isGroup,
|
||||
}) => {
|
||||
// TODO: modify to support removing dashboards from cache https://gitlab.com/gitlab-org/gitlab/-/issues/425513
|
||||
updateDashboardDetailsApolloCache({
|
||||
apolloClient,
|
||||
dashboard,
|
||||
slug,
|
||||
fullPath,
|
||||
isProject,
|
||||
isGroup,
|
||||
});
|
||||
updateDashboardsListApolloCache({ apolloClient, slug, dashboard, fullPath, isProject, isGroup });
|
||||
};
|
||||
|
||||
const filterUndefinedValues = (obj) => {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
return Object.fromEntries(Object.entries(obj).filter(([_, value]) => value !== undefined));
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a dashboard panel config into a GridStack item.
|
||||
*/
|
||||
export const parsePanelToGridItem = ({
|
||||
gridAttributes: { xPos, yPos, width, height, minHeight, minWidth, maxHeight, maxWidth },
|
||||
id,
|
||||
...rest
|
||||
}) =>
|
||||
// GridStack renders undefined layout values so we need to filter them out.
|
||||
filterUndefinedValues({
|
||||
x: xPos,
|
||||
y: yPos,
|
||||
w: width,
|
||||
h: height,
|
||||
minH: minHeight,
|
||||
minW: minWidth,
|
||||
maxH: maxHeight,
|
||||
maxW: maxWidth,
|
||||
id,
|
||||
props: {
|
||||
id,
|
||||
...rest,
|
||||
},
|
||||
});
|
||||
|
||||
export const createNewVisualizationPanel = (visualization) => ({
|
||||
id: getUniquePanelId(),
|
||||
title: humanize(visualization.slug),
|
||||
gridAttributes: {
|
||||
width: 4,
|
||||
height: 3,
|
||||
},
|
||||
queryOverrides: {},
|
||||
options: {},
|
||||
visualization: cloneWithoutReferences({ ...visualization, errors: null }),
|
||||
});
|
||||
|
|
@ -231,29 +231,7 @@ export default {
|
|||
internal: isNoteInternal,
|
||||
},
|
||||
},
|
||||
update(store, createNoteData) {
|
||||
const numErrors = createNoteData.data?.createNote?.errors?.length;
|
||||
|
||||
if (numErrors) {
|
||||
const { errors } = createNoteData.data.createNote;
|
||||
|
||||
// TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/346557
|
||||
// When a note only contains quick actions,
|
||||
// additional "helpful" messages are embedded in the errors field.
|
||||
// For instance, a note solely composed of "/assign @foobar" would
|
||||
// return a message "Commands only Assigned @root." as an error on creation
|
||||
// even though the quick action successfully executed.
|
||||
if (
|
||||
numErrors === 2 &&
|
||||
errors[0].includes('Commands only') &&
|
||||
errors[1].includes('Command names')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(createNoteData.data?.createNote?.errors[0]);
|
||||
}
|
||||
},
|
||||
update: this.onNoteUpdate,
|
||||
});
|
||||
/**
|
||||
* https://gitlab.com/gitlab-org/gitlab/-/issues/388314
|
||||
|
|
@ -282,6 +260,37 @@ export default {
|
|||
this.isEditing = true;
|
||||
this.$emit('startReplying');
|
||||
},
|
||||
onNoteUpdate(store, createNoteData) {
|
||||
const numErrors = createNoteData.data?.createNote?.errors?.length;
|
||||
|
||||
if (numErrors) {
|
||||
const { errors } = createNoteData.data.createNote;
|
||||
|
||||
// TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/503600
|
||||
// Refetching widgets as a temporary solution for dynamic updates
|
||||
// of the sidebar on changing the work item type
|
||||
if (numErrors === 2 && errors[1].includes('"type"')) {
|
||||
this.$apollo.queries.workItem.refetch();
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/346557
|
||||
// When a note only contains quick actions,
|
||||
// additional "helpful" messages are embedded in the errors field.
|
||||
// For instance, a note solely composed of "/assign @foobar" would
|
||||
// return a message "Commands only Assigned @root." as an error on creation
|
||||
// even though the quick action successfully executed.
|
||||
if (
|
||||
numErrors === 2 &&
|
||||
errors[0].includes('Commands only') &&
|
||||
errors[1].includes('Command names')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(createNoteData.data?.createNote?.errors[0]);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
$import-bar-height: $gl-spacing-scale-11;
|
||||
|
||||
.import-table-bar {
|
||||
height: $import-bar-height;
|
||||
min-height: $import-bar-height;
|
||||
top: $calc-application-header-height;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,12 +3,27 @@
|
|||
module Packages
|
||||
module Maven
|
||||
class FindOrCreatePackageService < BaseService
|
||||
include ::Gitlab::ExclusiveLeaseHelpers
|
||||
|
||||
SNAPSHOT_TERM = '-SNAPSHOT'
|
||||
MAX_FILE_NAME_LENGTH = 5000
|
||||
|
||||
def execute
|
||||
return ServiceResponse.error(message: 'File name is too long') if file_name_too_long?
|
||||
|
||||
return find_or_create_package if ::Feature.disabled?(:use_exclusive_lease_in_mvn_find_or_create_package,
|
||||
project)
|
||||
|
||||
# A temp exclusive lease to prevent race conditions. We will switch to use database `upsert`
|
||||
# when we have a unique index: https://gitlab.com/gitlab-org/gitlab/-/issues/424238#note_2187274213
|
||||
in_lock(lease_key, retries: 0) { find_or_create_package }
|
||||
rescue FailedToObtainLockError => e
|
||||
ServiceResponse.error(message: e.message, reason: :failed_to_obtain_lease)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_or_create_package
|
||||
package =
|
||||
::Packages::Maven::PackageFinder.new(current_user, project, path: path)
|
||||
.execute&.last
|
||||
|
|
@ -62,8 +77,6 @@ module Packages
|
|||
ServiceResponse.success(payload: { package: package })
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def duplicate_error?(package)
|
||||
return false if Namespace::PackageSetting.duplicates_allowed_for_package?(package)
|
||||
return false if Namespace::PackageSetting.matches_duplicate_exception?(package)
|
||||
|
|
@ -121,6 +134,10 @@ module Packages
|
|||
def file_name
|
||||
params[:file_name]
|
||||
end
|
||||
|
||||
def lease_key
|
||||
"#{self.class.name.underscore}:#{project.id}_#{path}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
name: use_exclusive_lease_in_mvn_find_or_create_package
|
||||
feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/424238
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/170916
|
||||
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/501019
|
||||
milestone: '17.6'
|
||||
group: group::package registry
|
||||
type: gitlab_com_derisk
|
||||
default_enabled: false
|
||||
|
|
@ -5,4 +5,4 @@ feature_category: source_code_management
|
|||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/156120
|
||||
milestone: '17.1'
|
||||
queued_migration_version: 20240612072331
|
||||
finalized_by: # version of the migration that finalized this BBM
|
||||
finalized_by: '20241110232543'
|
||||
|
|
|
|||
|
|
@ -8,13 +8,7 @@ description: Dependency list exported data
|
|||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/104361
|
||||
milestone: '15.7'
|
||||
gitlab_schema: gitlab_sec
|
||||
desired_sharding_key_migration_job_name: BackfillProjectIdToDependencyListExports
|
||||
# When migration is finalized, sharding keys should be defined as:
|
||||
#
|
||||
# sharding_key:
|
||||
# project_id: projects
|
||||
# group_id: namespaces
|
||||
# organization_id: organizations
|
||||
#
|
||||
# A multi-column not-null constraint should be added on these columns.
|
||||
sharding_key_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/501463
|
||||
sharding_key:
|
||||
organization_id: organizations
|
||||
group_id: namespaces
|
||||
project_id: projects
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddDependencyListExportsProjectIdGroupIdOrganizationIdNotNull < Gitlab::Database::Migration[2.2]
|
||||
milestone '17.6'
|
||||
disable_ddl_transaction!
|
||||
|
||||
TABLE_NAME = :dependency_list_exports
|
||||
|
||||
def up
|
||||
add_multi_column_not_null_constraint(TABLE_NAME,
|
||||
:organization_id,
|
||||
:group_id,
|
||||
:project_id,
|
||||
operator: '>',
|
||||
limit: 0
|
||||
)
|
||||
end
|
||||
|
||||
def down
|
||||
remove_multi_column_not_null_constraint(TABLE_NAME, :organization_id, :group_id, :project_id)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class FinalizeBackfillMergeRequestBlocksProjectId < Gitlab::Database::Migration[2.2]
|
||||
milestone '17.6'
|
||||
|
||||
disable_ddl_transaction!
|
||||
|
||||
restrict_gitlab_migration gitlab_schema: :gitlab_main_cell
|
||||
|
||||
def up
|
||||
ensure_batched_background_migration_is_finished(
|
||||
job_class_name: 'BackfillMergeRequestBlocksProjectId',
|
||||
table_name: :merge_request_blocks,
|
||||
column_name: :id,
|
||||
job_arguments: [:project_id, :merge_requests, :target_project_id, :blocking_merge_request_id],
|
||||
finalize: true
|
||||
)
|
||||
end
|
||||
|
||||
def down; end
|
||||
end
|
||||
|
|
@ -0,0 +1 @@
|
|||
ae9dc5b263714a4ddf7f33e0ad131063c02f237a3a0c3e04a5dd9554b7915d76
|
||||
|
|
@ -0,0 +1 @@
|
|||
19090cf24adcd8b3a05b87d3dfb3e932c2fb6dfb3d5f4225dfccca28dc64bace
|
||||
|
|
@ -10852,6 +10852,7 @@ CREATE TABLE dependency_list_exports (
|
|||
pipeline_id bigint,
|
||||
export_type smallint DEFAULT 0 NOT NULL,
|
||||
organization_id bigint,
|
||||
CONSTRAINT check_67a9c23e79 CHECK ((num_nonnulls(group_id, organization_id, project_id) > 0)),
|
||||
CONSTRAINT check_fff6fc9b2f CHECK ((char_length(file) <= 255))
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ To add a new visualization render type:
|
|||
|
||||
1. Create a new Vue component that accepts `data` and `options` properties.
|
||||
See [`line_chart.vue`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/line_chart.vue) as an example.
|
||||
1. Add your component to the list of conditional imports in [`panel_base.vue`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/app/assets/javascripts/vue_shared/components/customizable_dashboard/panels_base.vue#L13).
|
||||
1. Add your component to the list of conditional imports in [`panel_base.vue`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/assets/javascripts/vue_shared/components/customizable_dashboard/panels_base.vue#L13).
|
||||
1. Add your component to the schema's list of `AnalyticsVisualization` types in [`analytics_visualizations.json`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/app/validators/json_schemas/analytics_visualization.json).
|
||||
|
||||
##### Migrating existing components to visualizations
|
||||
|
|
@ -232,8 +232,8 @@ Here is an example component that renders a customizable dashboard:
|
|||
|
||||
```vue
|
||||
<script>
|
||||
import CustomizableDashboard from 'ee/vue_shared/components/customizable_dashboard/customizable_dashboard.vue';
|
||||
import PanelsBase from `ee/vue_shared/components/customizable_dashboard/panels_base.vue`;
|
||||
import CustomizableDashboard from '~/vue_shared/components/customizable_dashboard/customizable_dashboard.vue';
|
||||
import PanelsBase from `~/vue_shared/components/customizable_dashboard/panels_base.vue`;
|
||||
import { dashboard } from './constants';
|
||||
|
||||
export default {
|
||||
|
|
@ -311,7 +311,7 @@ The dashboard editor is only available when `dashboard.userDefined` is `true`.
|
|||
|
||||
```vue
|
||||
<script>
|
||||
import CustomizableDashboard from 'ee/vue_shared/components/customizable_dashboard/customizable_dashboard.vue';
|
||||
import CustomizableDashboard from '~/vue_shared/components/customizable_dashboard/customizable_dashboard.vue';
|
||||
import { s__ } from '~/locale';
|
||||
import { dashboard } from './constants';
|
||||
|
||||
|
|
|
|||
|
|
@ -554,7 +554,7 @@ Audit event types belong to the following product categories.
|
|||
| [`update_mismatched_group_saml_extern_uid`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/104791) | Triggered when the external UID is changed on a SAML identity. | **{check-circle}** Yes | **{check-circle}** Yes | GitLab [15.7](https://gitlab.com/gitlab-org/gitlab/-/issues/382256) | User |
|
||||
| [`user_access_locked`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/124169) | Event triggered when user access to the instance is locked | **{check-circle}** Yes | **{check-circle}** Yes | GitLab [16.2](https://gitlab.com/gitlab-org/modelops/anti-abuse/team-tasks/-/issues/244) | User |
|
||||
| [`user_access_unlocked`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/124973) | Event triggered when user access to the instance is unlocked | **{check-circle}** Yes | **{check-circle}** Yes | GitLab [16.2](https://gitlab.com/gitlab-org/modelops/anti-abuse/team-tasks/-/issues/244) | User |
|
||||
| [`user_disable_two_factor`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/89598) | Audit event triggered when user disables two factor authentication | **{check-circle}** Yes | **{check-circle}** Yes | GitLab [15.1](https://gitlab.com/gitlab-org/gitlab/-/issues/238177) | User |
|
||||
| [`user_disable_two_factor`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/89598) | Audit event triggered when user disables two factor authentication | **{check-circle}** Yes | **{check-circle}** Yes | GitLab [15.1](https://gitlab.com/gitlab-org/gitlab/-/issues/238177) | User, Group |
|
||||
| [`user_enable_admin_mode`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/104754) | Event triggered on enabling Admin Mode | **{check-circle}** Yes | **{check-circle}** Yes | GitLab [15.7](https://gitlab.com/gitlab-org/gitlab/-/issues/362101) | User |
|
||||
|
||||
### Team planning
|
||||
|
|
|
|||
|
|
@ -139,14 +139,13 @@ Create the group you want to import to and connect the source GitLab instance:
|
|||
## Select the groups and projects to import
|
||||
|
||||
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/385689) in GitLab 15.8, option to import groups with or without projects.
|
||||
> - **Import user memberships** checkbox [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/477734) in GitLab 17.6.
|
||||
|
||||
After you have authorized access to the source GitLab instance, you are redirected to the GitLab group
|
||||
importer page. Here you can see a list of the top-level groups on the connected source instance where you have the Owner
|
||||
role.
|
||||
After you have authorized access to the source GitLab instance, you are redirected to the GitLab group importer page. Here you can see a list of the top-level groups on the connected source instance where you have the Owner role.
|
||||
|
||||
1. By default, the proposed group namespaces match the names as they exist in source instance, but based on your permissions, you can choose to edit these names before you
|
||||
proceed to import any of them. Group and project paths must conform to naming [limitations](../../reserved_names.md#limitations-on-usernames-project-and-group-names-and-slugs)
|
||||
and are normalized if necessary to avoid import failures.
|
||||
If you do not want to import all user memberships from the source instance, ensure the **Import user memberships** checkbox is cleared. For example, the source instance might have 200 members, but you might want to import 50 members only. After the import completes, you can add more members to groups and projects.
|
||||
|
||||
1. By default, the proposed group namespaces match the names as they exist in source instance, but based on your permissions, you can choose to edit these names before you proceed to import any of them. Group and project paths must conform to naming [limitations](../../reserved_names.md#limitations-on-usernames-project-and-group-names-and-slugs) and are normalized if necessary to avoid import failures.
|
||||
1. Next to the groups you want to import, select either:
|
||||
- **Import with projects**. If this is not available, see [prerequisites](#prerequisites).
|
||||
- **Import without projects**.
|
||||
|
|
@ -154,8 +153,7 @@ role.
|
|||
1. After a group has been imported, select its GitLab path to open its GitLab URL.
|
||||
|
||||
WARNING:
|
||||
Importing groups with projects is in [beta](../../../policy/experiment-beta-support.md#beta). This feature is not
|
||||
ready for production use.
|
||||
Importing groups with projects is in [beta](../../../policy/experiment-beta-support.md#beta). This feature is not ready for production use.
|
||||
|
||||
## Group import history
|
||||
|
||||
|
|
|
|||
|
|
@ -10195,6 +10195,9 @@ msgstr ""
|
|||
msgid "BulkImport|Import is finished. Pick another name for re-import"
|
||||
msgstr ""
|
||||
|
||||
msgid "BulkImport|Import user memberships"
|
||||
msgstr ""
|
||||
|
||||
msgid "BulkImport|Import with projects"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -10279,6 +10282,9 @@ msgstr ""
|
|||
msgid "BulkImport|Select parent group"
|
||||
msgstr ""
|
||||
|
||||
msgid "BulkImport|Select whether user memberships in groups and projects are imported."
|
||||
msgstr ""
|
||||
|
||||
msgid "BulkImport|Showing %{start}-%{end} of %{total}"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe 'Projects > Members > Manage members', :js, feature_category: :onboarding do
|
||||
RSpec.describe 'Projects > Members > Manage members', :js, feature_category: :groups_and_projects do
|
||||
include ListboxHelpers
|
||||
include Features::MembersHelpers
|
||||
include Features::InviteMembersModalHelpers
|
||||
|
|
|
|||
|
|
@ -358,6 +358,7 @@ describe('import table', () => {
|
|||
variables: {
|
||||
importRequests: [
|
||||
{
|
||||
migrateMemberships: true,
|
||||
migrateProjects: true,
|
||||
newName: FAKE_GROUP.lastImportTarget.newName,
|
||||
sourceGroupId: FAKE_GROUP.id,
|
||||
|
|
@ -823,11 +824,15 @@ describe('import table', () => {
|
|||
targetNamespace: AVAILABLE_NAMESPACES[0].fullPath,
|
||||
newName: NEW_GROUPS[0].lastImportTarget.newName,
|
||||
sourceGroupId: NEW_GROUPS[0].id,
|
||||
migrateProjects: true,
|
||||
migrateMemberships: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
targetNamespace: AVAILABLE_NAMESPACES[0].fullPath,
|
||||
newName: NEW_GROUPS[1].lastImportTarget.newName,
|
||||
sourceGroupId: NEW_GROUPS[1].id,
|
||||
migrateProjects: true,
|
||||
migrateMemberships: true,
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
|
@ -909,7 +914,7 @@ describe('import table', () => {
|
|||
expect(findUnavailableFeaturesWarning().text()).toContain('projects (require v14.8.0)');
|
||||
});
|
||||
|
||||
it('does not renders alert when there are no unavailable features', async () => {
|
||||
it('does not render alert when there are no unavailable features', async () => {
|
||||
createComponent({
|
||||
bulkImportSourceGroups: () => ({
|
||||
nodes: FAKE_GROUPS,
|
||||
|
|
@ -965,12 +970,14 @@ describe('import table', () => {
|
|||
newName: NEW_GROUPS[0].lastImportTarget.newName,
|
||||
sourceGroupId: NEW_GROUPS[0].id,
|
||||
migrateProjects: true,
|
||||
migrateMemberships: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
targetNamespace: AVAILABLE_NAMESPACES[0].fullPath,
|
||||
newName: NEW_GROUPS[1].lastImportTarget.newName,
|
||||
sourceGroupId: NEW_GROUPS[1].id,
|
||||
migrateProjects: true,
|
||||
migrateMemberships: true,
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
|
@ -991,16 +998,69 @@ describe('import table', () => {
|
|||
newName: NEW_GROUPS[0].lastImportTarget.newName,
|
||||
sourceGroupId: NEW_GROUPS[0].id,
|
||||
migrateProjects: false,
|
||||
migrateMemberships: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
targetNamespace: AVAILABLE_NAMESPACES[0].fullPath,
|
||||
newName: NEW_GROUPS[1].lastImportTarget.newName,
|
||||
sourceGroupId: NEW_GROUPS[1].id,
|
||||
migrateProjects: false,
|
||||
migrateMemberships: true,
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('migrateMemberships', () => {
|
||||
const findImportUserMembershipsCheckbox = () =>
|
||||
wrapper.find('[data-testid="toggle-import-user-memberships"]');
|
||||
|
||||
it('checkbox is rendered as checked by default', async () => {
|
||||
await createComponent({
|
||||
bulkImportSourceGroups: () => ({
|
||||
nodes: FAKE_GROUPS,
|
||||
pageInfo: FAKE_PAGE_INFO,
|
||||
versionValidation: FAKE_VERSION_VALIDATION,
|
||||
}),
|
||||
});
|
||||
|
||||
await waitForPromises();
|
||||
expect(findImportUserMembershipsCheckbox().element.checked).toBe(true);
|
||||
});
|
||||
|
||||
it('is included as false in the importGroupsMutation when checkbox is unchecked', async () => {
|
||||
await createComponent({
|
||||
bulkImportSourceGroups: () => ({
|
||||
nodes: FAKE_GROUPS,
|
||||
pageInfo: FAKE_PAGE_INFO,
|
||||
versionValidation: FAKE_VERSION_VALIDATION,
|
||||
}),
|
||||
});
|
||||
const mutateSpy = jest.spyOn(apolloProvider.defaultClient, 'mutate');
|
||||
|
||||
await waitForPromises();
|
||||
await findImportUserMembershipsCheckbox().setChecked(false);
|
||||
await nextTick();
|
||||
|
||||
await findRowImportDropdownAtIndex(0).trigger('click');
|
||||
await waitForPromises();
|
||||
|
||||
expect(mutateSpy).toHaveBeenCalledWith({
|
||||
mutation: importGroupsMutation,
|
||||
variables: {
|
||||
importRequests: [
|
||||
{
|
||||
migrateMemberships: false,
|
||||
migrateProjects: true,
|
||||
newName: FAKE_GROUP.lastImportTarget.newName,
|
||||
sourceGroupId: FAKE_GROUP.id,
|
||||
targetNamespace: AVAILABLE_NAMESPACES[0].fullPath,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,256 @@
|
|||
import { GlAlert, GlDrawer, GlLoadingIcon, GlFormCheckbox } from '@gitlab/ui';
|
||||
import AvailableVisualizationsDrawer from '~/vue_shared/components/customizable_dashboard/dashboard_editor/available_visualizations_drawer.vue';
|
||||
import api from '~/api';
|
||||
import { humanize } from '~/lib/utils/text_utility';
|
||||
import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
|
||||
import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
|
||||
import { stubComponent } from 'helpers/stub_component';
|
||||
import { TEST_VISUALIZATIONS_GRAPHQL_SUCCESS_RESPONSE } from '../mock_data';
|
||||
|
||||
jest.mock('~/lib/utils/dom_utils', () => ({
|
||||
getContentWrapperHeight: () => '123px',
|
||||
}));
|
||||
|
||||
describe('AvailableVisualizationsDrawer', () => {
|
||||
/** @type {import('helpers/vue_test_utils_helper').ExtendedWrapper} */
|
||||
let wrapper;
|
||||
|
||||
const allTypes = ['SingleStat', 'LineChart', 'DataTable', 'BarChart'];
|
||||
|
||||
const createVisualizations = (types = ['SingleStat']) => {
|
||||
const visualization = {
|
||||
...TEST_VISUALIZATIONS_GRAPHQL_SUCCESS_RESPONSE.data.project
|
||||
.customizableDashboardVisualizations.nodes[0],
|
||||
};
|
||||
|
||||
return types.map((type, index) => ({
|
||||
...visualization,
|
||||
slug: `${visualization.slug}-${index}`,
|
||||
type,
|
||||
}));
|
||||
};
|
||||
|
||||
const stubs = {
|
||||
GlDrawer,
|
||||
GlFormCheckbox: stubComponent(GlFormCheckbox, {
|
||||
props: ['value'],
|
||||
template: `<div><input type="checkbox" /><slot></slot></div>`,
|
||||
}),
|
||||
};
|
||||
|
||||
const createWrapper = (props = {}, mountFn = shallowMountExtended, options = {}) => {
|
||||
wrapper = mountFn(AvailableVisualizationsDrawer, {
|
||||
propsData: {
|
||||
visualizations: [],
|
||||
loading: false,
|
||||
hasError: false,
|
||||
open: false,
|
||||
...props,
|
||||
},
|
||||
stubs: mountFn === shallowMountExtended ? stubs : {},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
const findDrawer = () => wrapper.findComponent(GlDrawer);
|
||||
const findAddButton = () => wrapper.findByTestId('add-button');
|
||||
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
|
||||
const findListItems = () => wrapper.findAll('li');
|
||||
const findListItemBySlug = (slug) => wrapper.findByTestId(`list-item-${slug}`);
|
||||
const findCheckboxBySlug = (slug) => findListItemBySlug(slug).findComponent(GlFormCheckbox);
|
||||
const findCategoryTitles = () => wrapper.findAllByTestId('category-title');
|
||||
const findAlert = () => wrapper.findComponent(GlAlert);
|
||||
|
||||
describe('default behaviour', () => {
|
||||
beforeEach(() => {
|
||||
createWrapper();
|
||||
});
|
||||
|
||||
it('renders the drawer', () => {
|
||||
expect(findDrawer().props()).toMatchObject({
|
||||
open: false,
|
||||
headerHeight: '0',
|
||||
zIndex: DRAWER_Z_INDEX,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the drawer is open', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(api, 'trackRedisCounterEvent').mockImplementation(() => {});
|
||||
createWrapper({ open: true });
|
||||
});
|
||||
|
||||
it('renders the opened drawer', () => {
|
||||
expect(findDrawer().text()).toContain('Add visualization');
|
||||
expect(findDrawer().props('headerHeight')).toBe(getContentWrapperHeight());
|
||||
});
|
||||
|
||||
it('disables the add button', () => {
|
||||
expect(findAddButton().attributes().disabled).toBe('true');
|
||||
});
|
||||
|
||||
it('emits close event when the drawer is closed', async () => {
|
||||
await findDrawer().vm.$emit('close');
|
||||
|
||||
expect(wrapper.emitted('close')).toEqual([[]]);
|
||||
});
|
||||
|
||||
it('does not render the loading icon', () => {
|
||||
expect(findLoadingIcon().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the drawer is open and is loading', () => {
|
||||
beforeEach(() => {
|
||||
createWrapper({ open: true, loading: true });
|
||||
});
|
||||
|
||||
it('renders the loading icon', () => {
|
||||
expect(findLoadingIcon().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not render any list items', () => {
|
||||
expect(findListItems()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the drawer is open and visualizations have been loaded', () => {
|
||||
const visualizations = createVisualizations(allTypes);
|
||||
|
||||
beforeEach(() => {
|
||||
createWrapper({ open: true, loading: false, visualizations }, shallowMountExtended, {
|
||||
attachTo: document.body,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render the loading icon', () => {
|
||||
expect(findLoadingIcon().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('renders the category titles', () => {
|
||||
expect(findCategoryTitles().wrappers).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('renders list items for each visualization', () => {
|
||||
expect(findListItems()).toHaveLength(visualizations.length);
|
||||
});
|
||||
|
||||
it('renders a checkbox for each visualization', () => {
|
||||
visualizations.forEach((visualization) => {
|
||||
const checkbox = findCheckboxBySlug(visualization.slug);
|
||||
|
||||
expect(checkbox.text()).toContain(humanize(visualization.slug));
|
||||
expect(checkbox.props('value')).toStrictEqual(visualization);
|
||||
});
|
||||
});
|
||||
|
||||
it('sets focus on the first visualization checkbox', () => {
|
||||
expect(findListItems().at(0).find('input').element).toStrictEqual(document.activeElement);
|
||||
});
|
||||
|
||||
describe('and a user clicks on some list items', () => {
|
||||
beforeEach(async () => {
|
||||
await findListItemBySlug(visualizations[0].slug).trigger('click');
|
||||
await findListItemBySlug(visualizations[1].slug).trigger('click');
|
||||
});
|
||||
|
||||
it('enables the add button', () => {
|
||||
expect(findAddButton().attributes('disabled')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('emits the selected visualizations when the add button is clicked', async () => {
|
||||
await findAddButton().vm.$emit('click');
|
||||
|
||||
expect(wrapper.emitted('select')[0][0]).toMatchObject([
|
||||
visualizations[0],
|
||||
visualizations[1],
|
||||
]);
|
||||
});
|
||||
|
||||
it('clears the selected visualizations after add button is clicked', async () => {
|
||||
await findAddButton().vm.$emit('click');
|
||||
|
||||
expect(findAddButton().attributes().disabled).toBe('true');
|
||||
});
|
||||
|
||||
it('deselects the selected visualizations when the same list items are clicked again', async () => {
|
||||
await findListItemBySlug(visualizations[0].slug).trigger('click');
|
||||
await findListItemBySlug(visualizations[1].slug).trigger('click');
|
||||
|
||||
expect(findAddButton().attributes().disabled).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('and a user selects using checkboxes', () => {
|
||||
beforeEach(async () => {
|
||||
createWrapper({ open: true, loading: false, visualizations }, mountExtended);
|
||||
|
||||
await findCheckboxBySlug(visualizations[2].slug).find('input').setChecked(true);
|
||||
await findCheckboxBySlug(visualizations[3].slug).find('input').setChecked(true);
|
||||
});
|
||||
|
||||
it('enables the add button', () => {
|
||||
expect(findAddButton().attributes('disabled')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('emits the selected visualizations when the add button is clicked', async () => {
|
||||
await findAddButton().vm.$emit('click');
|
||||
|
||||
expect(wrapper.emitted('select')[0][0]).toMatchObject([
|
||||
visualizations[2],
|
||||
visualizations[3],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the drawer is open and there is an error', () => {
|
||||
beforeEach(() => {
|
||||
createWrapper({ open: true, loading: false, hasError: true });
|
||||
});
|
||||
|
||||
it('does not render the loading icon', () => {
|
||||
expect(findLoadingIcon().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not render any list items', () => {
|
||||
expect(findListItems()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders an error', () => {
|
||||
const alert = findAlert();
|
||||
|
||||
expect(alert.exists()).toBe(true);
|
||||
expect(alert.text()).toContain(
|
||||
'Something went wrong while loading available visualizations. Refresh the page to try again.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('category titles', () => {
|
||||
const allTitles = ['Single stats', 'Tables', 'Charts'];
|
||||
|
||||
it.each`
|
||||
types | categoryTitles
|
||||
${['SingleStat']} | ${['Single stats']}
|
||||
${['SingleStat', 'DataTable']} | ${['Single stats', 'Tables']}
|
||||
${['SingleStat', 'DataTable']} | ${['Single stats', 'Tables']}
|
||||
${['SingleStat', 'DataTable', 'Line Chart']} | ${allTitles}
|
||||
${['SingleStat', 'DataTable', 'Line Chart']} | ${allTitles}
|
||||
${allTypes} | ${allTitles}
|
||||
${[...allTypes, 'FooBar']} | ${allTitles}
|
||||
`('renders the titles $categoryTitles for types $types', async ({ types, categoryTitles }) => {
|
||||
await createWrapper({
|
||||
open: true,
|
||||
loading: false,
|
||||
visualizations: createVisualizations(types),
|
||||
});
|
||||
|
||||
const renderedTitles = findCategoryTitles().wrappers.map((w) => w.text());
|
||||
|
||||
expect(renderedTitles.sort()).toStrictEqual(categoryTitles.sort());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { GlToggle, GlIcon } from '@gitlab/ui';
|
||||
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
|
||||
import AnonUsersFilter from '~/vue_shared/components/customizable_dashboard/filters/anon_users_filter.vue';
|
||||
|
||||
describe('AnonUsersFilter', () => {
|
||||
/** @type {import('helpers/vue_test_utils_helper').ExtendedWrapper} */
|
||||
let wrapper;
|
||||
|
||||
const createWrapper = (props = {}) => {
|
||||
wrapper = shallowMountExtended(AnonUsersFilter, {
|
||||
directives: {
|
||||
GlTooltip: createMockDirective('gl-tooltip'),
|
||||
},
|
||||
propsData: {
|
||||
...props,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const findToggle = () => wrapper.findComponent(GlToggle);
|
||||
const findHelpIcon = () => wrapper.findComponent(GlIcon);
|
||||
|
||||
describe('default behaviour', () => {
|
||||
it('renders the toggle', () => {
|
||||
createWrapper({ value: false });
|
||||
|
||||
expect(findToggle().props()).toMatchObject({
|
||||
label: 'Exclude anonymous users',
|
||||
labelPosition: 'left',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([true, false])('sets the toggle value when the value prop is %s', (value) => {
|
||||
createWrapper({ value });
|
||||
|
||||
expect(findToggle().props('value')).toBe(value);
|
||||
});
|
||||
|
||||
it('bubbles up the "change" event', async () => {
|
||||
createWrapper({ value: false });
|
||||
|
||||
await findToggle().vm.$emit('change', true);
|
||||
|
||||
expect(wrapper.emitted('change')).toStrictEqual([[true]]);
|
||||
});
|
||||
|
||||
it('should show an icon with a tooltip explaining the filter', () => {
|
||||
createWrapper({ value: false });
|
||||
|
||||
const helpIcon = findHelpIcon();
|
||||
const tooltip = getBinding(helpIcon.element, 'gl-tooltip');
|
||||
|
||||
expect(helpIcon.props('name')).toBe('information-o');
|
||||
expect(helpIcon.attributes('title')).toBe(
|
||||
'View metrics only for users who have consented to activity tracking.',
|
||||
);
|
||||
expect(tooltip).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
import { GlCollapsibleListbox, GlDaterangePicker, GlIcon } from '@gitlab/ui';
|
||||
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
|
||||
import DateRangeFilter from '~/vue_shared/components/customizable_dashboard/filters/date_range_filter.vue';
|
||||
import {
|
||||
DATE_RANGE_OPTIONS,
|
||||
DEFAULT_SELECTED_OPTION_INDEX,
|
||||
TODAY,
|
||||
} from '~/vue_shared/components/customizable_dashboard/filters/constants';
|
||||
import { dateRangeOptionToFilter } from '~/vue_shared/components/customizable_dashboard/utils';
|
||||
|
||||
describe('DateRangeFilter', () => {
|
||||
/** @type {import('helpers/vue_test_utils_helper').ExtendedWrapper} */
|
||||
let wrapper;
|
||||
|
||||
const dateRangeOption = DATE_RANGE_OPTIONS.find((option) => !option.showDateRangePicker);
|
||||
const customRangeOption = DATE_RANGE_OPTIONS.find((option) => option.showDateRangePicker);
|
||||
|
||||
const createWrapper = (props = {}) => {
|
||||
wrapper = shallowMountExtended(DateRangeFilter, {
|
||||
directives: {
|
||||
GlTooltip: createMockDirective('gl-tooltip'),
|
||||
},
|
||||
propsData: {
|
||||
...props,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const findDateRangePicker = () => wrapper.findComponent(GlDaterangePicker);
|
||||
const findCollapsibleListBox = () => wrapper.findComponent(GlCollapsibleListbox);
|
||||
const findHelpIcon = () => wrapper.findComponent(GlIcon);
|
||||
|
||||
describe('default behaviour', () => {
|
||||
beforeEach(() => {
|
||||
createWrapper();
|
||||
});
|
||||
|
||||
it('renders a dropdown with the value set to the default selected option', () => {
|
||||
expect(findCollapsibleListBox().props().selected).toBe(
|
||||
DATE_RANGE_OPTIONS[DEFAULT_SELECTED_OPTION_INDEX].key,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a dropdown item for each option', () => {
|
||||
DATE_RANGE_OPTIONS.forEach((option, idx) => {
|
||||
expect(findCollapsibleListBox().props('items').at(idx).text).toBe(option.text);
|
||||
});
|
||||
});
|
||||
|
||||
it('emits the selected date range when a dropdown item with a date range is clicked', () => {
|
||||
findCollapsibleListBox().vm.$emit('select', dateRangeOption.key);
|
||||
|
||||
expect(wrapper.emitted('change')).toStrictEqual([[dateRangeOptionToFilter(dateRangeOption)]]);
|
||||
});
|
||||
|
||||
it('should show an icon with a tooltip explaining dates are in UTC', () => {
|
||||
const helpIcon = findHelpIcon();
|
||||
const tooltip = getBinding(helpIcon.element, 'gl-tooltip');
|
||||
|
||||
expect(helpIcon.props('name')).toBe('information-o');
|
||||
expect(helpIcon.attributes('title')).toBe(
|
||||
'Dates and times are displayed in the UTC timezone',
|
||||
);
|
||||
expect(tooltip).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not have the class "gl-flex-col"', () => {
|
||||
expect(wrapper.classes()).not.toContain('gl-flex-col');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a default option', () => {
|
||||
beforeEach(() => {
|
||||
createWrapper({ defaultOption: customRangeOption.key });
|
||||
});
|
||||
|
||||
it('selects the provided default option', () => {
|
||||
expect(findCollapsibleListBox().props().selected).toBe(customRangeOption.key);
|
||||
});
|
||||
});
|
||||
|
||||
describe('date range picker', () => {
|
||||
describe('by default', () => {
|
||||
const { startDate, endDate } = DATE_RANGE_OPTIONS[DEFAULT_SELECTED_OPTION_INDEX];
|
||||
|
||||
beforeEach(() => {
|
||||
createWrapper({ startDate, endDate });
|
||||
});
|
||||
|
||||
it('adds the class "gl-flex-col" when a custom date range is selected', async () => {
|
||||
await findCollapsibleListBox().vm.$emit('select', customRangeOption.key);
|
||||
|
||||
expect(wrapper.classes()).toContain('gl-flex-col');
|
||||
});
|
||||
|
||||
it('does not emit a new date range when the option shows the date range picker', async () => {
|
||||
await findCollapsibleListBox().vm.$emit('select', customRangeOption.key);
|
||||
|
||||
expect(wrapper.emitted('change')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('shows the date range picker with the provided date range when the option enables it', async () => {
|
||||
expect(findDateRangePicker().exists()).toBe(false);
|
||||
|
||||
await findCollapsibleListBox().vm.$emit('select', customRangeOption.key);
|
||||
|
||||
expect(findDateRangePicker().props()).toMatchObject({
|
||||
toLabel: 'To',
|
||||
fromLabel: 'From',
|
||||
tooltip: null,
|
||||
defaultMaxDate: TODAY,
|
||||
maxDateRange: 0,
|
||||
value: {
|
||||
startDate,
|
||||
endDate,
|
||||
},
|
||||
defaultStartDate: startDate,
|
||||
defaultEndDate: endDate,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{ dateRangeLimit: 0, expectedTooltip: null },
|
||||
{ dateRangeLimit: 1, expectedTooltip: 'Date range limited to 1 day' },
|
||||
{ dateRangeLimit: 12, expectedTooltip: 'Date range limited to 12 days' },
|
||||
{ dateRangeLimit: 31, expectedTooltip: 'Date range limited to 31 days' },
|
||||
])(
|
||||
'when given a date range limit of $dateRangeLimit',
|
||||
({ dateRangeLimit, expectedTooltip }) => {
|
||||
beforeEach(() => {
|
||||
createWrapper({ dateRangeLimit });
|
||||
});
|
||||
|
||||
it('shows the date range picker with date range limit applied', async () => {
|
||||
expect(findDateRangePicker().exists()).toBe(false);
|
||||
|
||||
await findCollapsibleListBox().vm.$emit('select', customRangeOption.key);
|
||||
|
||||
expect(findDateRangePicker().props()).toMatchObject({
|
||||
tooltip: expectedTooltip,
|
||||
maxDateRange: dateRangeLimit,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,277 @@
|
|||
import { nextTick } from 'vue';
|
||||
import { GridStack } from 'gridstack';
|
||||
import { breakpoints } from '@gitlab/ui/dist/utils';
|
||||
import * as Sentry from '~/sentry/sentry_browser_wrapper';
|
||||
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import GridstackWrapper from '~/vue_shared/components/customizable_dashboard/gridstack_wrapper.vue';
|
||||
import {
|
||||
GRIDSTACK_MARGIN,
|
||||
GRIDSTACK_CSS_HANDLE,
|
||||
GRIDSTACK_CELL_HEIGHT,
|
||||
GRIDSTACK_MIN_ROW,
|
||||
} from '~/vue_shared/components/customizable_dashboard/constants';
|
||||
import { loadCSSFile } from '~/lib/utils/css_utils';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import {
|
||||
parsePanelToGridItem,
|
||||
createNewVisualizationPanel,
|
||||
} from '~/vue_shared/components/customizable_dashboard/utils';
|
||||
import { dashboard, builtinDashboard } from './mock_data';
|
||||
|
||||
const mockGridSetStatic = jest.fn();
|
||||
const mockGridDestroy = jest.fn();
|
||||
const mockGridLoad = jest.fn();
|
||||
|
||||
jest.mock('gridstack', () => {
|
||||
const actualModule = jest.requireActual('gridstack');
|
||||
|
||||
return {
|
||||
GridStack: {
|
||||
init: jest.fn().mockImplementation((config) => {
|
||||
const instance = actualModule.GridStack.init(config);
|
||||
instance.load = mockGridLoad.mockImplementation(instance.load);
|
||||
instance.setStatic = mockGridSetStatic;
|
||||
instance.destroy = mockGridDestroy;
|
||||
return instance;
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('~/lib/utils/css_utils', () => ({
|
||||
loadCSSFile: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('GridstackWrapper', () => {
|
||||
/** @type {import('helpers/vue_test_utils_helper').ExtendedWrapper} */
|
||||
let wrapper;
|
||||
let panelSlots = [];
|
||||
|
||||
const createWrapper = (props = {}) => {
|
||||
wrapper = shallowMountExtended(GridstackWrapper, {
|
||||
propsData: {
|
||||
value: dashboard,
|
||||
...props,
|
||||
},
|
||||
scopedSlots: {
|
||||
panel(data) {
|
||||
panelSlots.push(data);
|
||||
},
|
||||
},
|
||||
attachTo: document.body,
|
||||
});
|
||||
};
|
||||
|
||||
const findGridStackPanels = () => wrapper.findAllByTestId('grid-stack-panel');
|
||||
const findGridItemContentById = (panelId) =>
|
||||
wrapper.find(`[gs-id="${panelId}"]`).find('.grid-stack-item-content');
|
||||
const findPanelById = (panelId) => wrapper.find(`#${panelId}`);
|
||||
|
||||
afterEach(() => {
|
||||
mockGridSetStatic.mockReset();
|
||||
mockGridDestroy.mockReset();
|
||||
|
||||
panelSlots = [];
|
||||
});
|
||||
|
||||
describe('default behaviour', () => {
|
||||
beforeEach(() => {
|
||||
loadCSSFile.mockResolvedValue();
|
||||
createWrapper();
|
||||
});
|
||||
|
||||
it('sets up GridStack', () => {
|
||||
expect(GridStack.init).toHaveBeenCalledWith({
|
||||
alwaysShowResizeHandle: true,
|
||||
staticGrid: true,
|
||||
animate: true,
|
||||
margin: GRIDSTACK_MARGIN,
|
||||
handle: GRIDSTACK_CSS_HANDLE,
|
||||
cellHeight: GRIDSTACK_CELL_HEIGHT,
|
||||
minRow: GRIDSTACK_MIN_ROW,
|
||||
columnOpts: { breakpoints: [{ w: breakpoints.md, c: 1 }] },
|
||||
float: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the parsed dashboard config', () => {
|
||||
expect(mockGridLoad).toHaveBeenCalledWith(dashboard.panels.map(parsePanelToGridItem));
|
||||
});
|
||||
|
||||
it('does not render the grab cursor on grid panels', () => {
|
||||
expect(findGridStackPanels().at(0).classes()).not.toContain('gl-cursor-grab');
|
||||
});
|
||||
|
||||
it('renders a panel once it has been added', async () => {
|
||||
const newPanel = createNewVisualizationPanel(builtinDashboard.panels[0].visualization);
|
||||
|
||||
expect(findPanelById(newPanel.id).exists()).toBe(false);
|
||||
|
||||
wrapper.setProps({
|
||||
value: {
|
||||
...dashboard,
|
||||
panels: [...dashboard.panels, newPanel],
|
||||
},
|
||||
});
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
const gridItem = findGridItemContentById(newPanel.id);
|
||||
const panel = findPanelById(newPanel.id);
|
||||
|
||||
expect(panel.element.parentElement).toBe(gridItem.element);
|
||||
});
|
||||
|
||||
it('does not render a removed panel', async () => {
|
||||
const panelToRemove = dashboard.panels[0];
|
||||
|
||||
expect(findGridStackPanels()).toHaveLength(dashboard.panels.length);
|
||||
expect(findPanelById(panelToRemove.id).exists()).toBe(true);
|
||||
|
||||
wrapper.setProps({
|
||||
value: {
|
||||
...dashboard,
|
||||
panels: dashboard.panels.filter((panel) => panel.id !== panelToRemove.id),
|
||||
},
|
||||
});
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(findGridStackPanels()).toHaveLength(dashboard.panels.length - 1);
|
||||
expect(findPanelById(panelToRemove.id).exists()).toBe(false);
|
||||
});
|
||||
|
||||
describe.each(dashboard.panels.map((panel, index) => [panel, index]))(
|
||||
'for dashboard panel %#',
|
||||
(panel, index) => {
|
||||
it('renders a grid panel', () => {
|
||||
const element = findGridStackPanels().at(index);
|
||||
|
||||
expect(element.attributes().id).toContain('panel-');
|
||||
});
|
||||
|
||||
it('sets the panel props on the panel slot', () => {
|
||||
const { gridAttributes, ...panelProps } = panel;
|
||||
|
||||
expect(panelSlots[index]).toStrictEqual({ panel: panelProps });
|
||||
});
|
||||
|
||||
it("renders the panel inside the grid item's content", async () => {
|
||||
const gridItem = findGridItemContentById(panel.id);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(findGridStackPanels().at(index).element.parentElement).toBe(gridItem.element);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('when editing = true', () => {
|
||||
beforeEach(() => {
|
||||
loadCSSFile.mockResolvedValue();
|
||||
createWrapper({ editing: true });
|
||||
|
||||
return waitForPromises();
|
||||
});
|
||||
|
||||
it('initializes GridStack with staticGrid = false', () => {
|
||||
expect(GridStack.init).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
staticGrid: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('calls GridStack.setStatic when the editing prop changes', async () => {
|
||||
wrapper.setProps({ editing: false });
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(mockGridSetStatic).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('renders the grab cursor on grid panels', () => {
|
||||
expect(findGridStackPanels().at(0).classes()).toContain('gl-cursor-grab');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the grid changes', () => {
|
||||
beforeEach(async () => {
|
||||
loadCSSFile.mockResolvedValue();
|
||||
createWrapper();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
const gridEl = wrapper.find('.grid-stack').element;
|
||||
const event = new CustomEvent('change', {
|
||||
detail: [
|
||||
{
|
||||
id: dashboard.panels[1].id,
|
||||
x: 10,
|
||||
y: 20,
|
||||
w: 30,
|
||||
h: 40,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
gridEl.dispatchEvent(event);
|
||||
});
|
||||
|
||||
it('emits the changed dashboard object', () => {
|
||||
expect(wrapper.emitted('input')).toStrictEqual([
|
||||
[
|
||||
{
|
||||
...dashboard,
|
||||
panels: [
|
||||
dashboard.panels[0],
|
||||
{
|
||||
...dashboard.panels[1],
|
||||
gridAttributes: {
|
||||
...dashboard.panels[1].gridAttributes,
|
||||
xPos: 10,
|
||||
yPos: 20,
|
||||
width: 30,
|
||||
height: 40,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when an error occurs while loading the CSS', () => {
|
||||
const sentryError = new Error('Network error');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(Sentry, 'captureException');
|
||||
loadCSSFile.mockRejectedValue(sentryError);
|
||||
|
||||
createWrapper();
|
||||
|
||||
return waitForPromises();
|
||||
});
|
||||
|
||||
it('reports the error to sentry', () => {
|
||||
expect(Sentry.captureException.mock.calls[0][0]).toStrictEqual(sentryError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('beforeDestroy', () => {
|
||||
beforeEach(async () => {
|
||||
loadCSSFile.mockResolvedValue();
|
||||
createWrapper();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('cleans up the gridstack instance', () => {
|
||||
expect(mockGridDestroy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,298 @@
|
|||
import { getUniquePanelId } from '~/vue_shared/components/customizable_dashboard/utils';
|
||||
|
||||
const cubeLineChart = {
|
||||
type: 'LineChart',
|
||||
slug: 'cube_line_chart',
|
||||
title: 'Cube line chart',
|
||||
data: {
|
||||
type: 'cube_analytics',
|
||||
query: {
|
||||
users: {
|
||||
measures: ['TrackedEvents.count'],
|
||||
dimensions: ['TrackedEvents.eventType'],
|
||||
},
|
||||
},
|
||||
},
|
||||
options: {
|
||||
xAxis: {
|
||||
name: 'Time',
|
||||
type: 'time',
|
||||
},
|
||||
yAxis: {
|
||||
name: 'Counts',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const dashboard = {
|
||||
id: 'analytics_overview',
|
||||
slug: 'analytics_overview',
|
||||
title: 'Analytics Overview',
|
||||
description: 'This is a dashboard',
|
||||
userDefined: true,
|
||||
panels: [
|
||||
{
|
||||
title: 'Test A',
|
||||
gridAttributes: { width: 3, height: 3 },
|
||||
visualization: cubeLineChart,
|
||||
queryOverrides: null,
|
||||
id: getUniquePanelId(),
|
||||
},
|
||||
{
|
||||
title: 'Test B',
|
||||
gridAttributes: { width: 2, height: 4, minHeight: 2, minWidth: 2 },
|
||||
visualization: cubeLineChart,
|
||||
queryOverrides: {
|
||||
limit: 200,
|
||||
},
|
||||
id: getUniquePanelId(),
|
||||
},
|
||||
],
|
||||
status: null,
|
||||
errors: null,
|
||||
};
|
||||
|
||||
export const builtinDashboard = {
|
||||
title: 'Analytics Overview',
|
||||
description: 'This is a built-in description',
|
||||
panels: [
|
||||
{
|
||||
title: 'Test A',
|
||||
gridAttributes: { width: 3, height: 3 },
|
||||
visualization: cubeLineChart,
|
||||
queryOverrides: {},
|
||||
id: getUniquePanelId(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const betaDashboard = {
|
||||
title: 'Test Dashboard',
|
||||
description: 'This dashboard is a work-in-progress',
|
||||
status: 'beta',
|
||||
panels: [
|
||||
{
|
||||
title: 'Test A',
|
||||
gridAttributes: { width: 3, height: 3 },
|
||||
visualization: cubeLineChart,
|
||||
queryOverrides: {},
|
||||
id: getUniquePanelId(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const mockDateRangeFilterChangePayload = {
|
||||
startDate: new Date('2016-01-01'),
|
||||
endDate: new Date('2016-02-01'),
|
||||
dateRangeOption: 'foo',
|
||||
};
|
||||
|
||||
export const mockPanel = {
|
||||
title: 'Test A',
|
||||
gridAttributes: {
|
||||
width: 1,
|
||||
height: 2,
|
||||
xPos: 0,
|
||||
yPos: 3,
|
||||
minWidth: 1,
|
||||
minHeight: 2,
|
||||
maxWidth: 1,
|
||||
maxHeight: 2,
|
||||
},
|
||||
visualization: cubeLineChart,
|
||||
queryOverrides: {},
|
||||
id: getUniquePanelId(),
|
||||
};
|
||||
|
||||
export const TEST_VISUALIZATION = () => ({
|
||||
version: 1,
|
||||
type: 'LineChart',
|
||||
slug: 'test_visualization',
|
||||
data: {
|
||||
type: 'cube_analytics',
|
||||
query: {
|
||||
measures: ['TrackedEvents.count'],
|
||||
timeDimensions: [
|
||||
{
|
||||
dimension: 'TrackedEvents.utcTime',
|
||||
granularity: 'day',
|
||||
},
|
||||
],
|
||||
limit: 100,
|
||||
timezone: 'UTC',
|
||||
filters: [],
|
||||
dimensions: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const TEST_EMPTY_DASHBOARD_SVG_PATH = 'illustration/empty-state/empty-dashboard-md';
|
||||
|
||||
export const TEST_VISUALIZATIONS_GRAPHQL_SUCCESS_RESPONSE = {
|
||||
data: {
|
||||
project: {
|
||||
id: 'gid://gitlab/Project/73',
|
||||
customizableDashboardVisualizations: {
|
||||
nodes: [
|
||||
{
|
||||
slug: 'another_one',
|
||||
type: 'SingleStat',
|
||||
data: {
|
||||
type: 'cube_analytics',
|
||||
query: {
|
||||
measures: ['TrackedEvents.count'],
|
||||
filters: [
|
||||
{
|
||||
member: 'TrackedEvents.event',
|
||||
operator: 'equals',
|
||||
values: ['click'],
|
||||
},
|
||||
],
|
||||
limit: 100,
|
||||
timezone: 'UTC',
|
||||
dimensions: [],
|
||||
timeDimensions: [],
|
||||
},
|
||||
},
|
||||
options: {},
|
||||
__typename: 'CustomizableDashboardVisualization',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TEST_CUSTOM_DASHBOARDS_PROJECT = {
|
||||
fullPath: 'test/test-dashboards',
|
||||
id: 123,
|
||||
name: 'test-dashboards',
|
||||
defaultBranch: 'some-branch',
|
||||
};
|
||||
|
||||
export const getGraphQLDashboard = (options = {}, withPanels = true) => {
|
||||
const newDashboard = {
|
||||
slug: '',
|
||||
title: '',
|
||||
userDefined: false,
|
||||
status: null,
|
||||
description: 'Understand your audience',
|
||||
__typename: 'CustomizableDashboard',
|
||||
errors: [],
|
||||
...options,
|
||||
};
|
||||
|
||||
if (withPanels) {
|
||||
return {
|
||||
...newDashboard,
|
||||
panels: {
|
||||
nodes: [
|
||||
{
|
||||
title: 'Daily Active Users',
|
||||
gridAttributes: {
|
||||
yPos: 1,
|
||||
xPos: 0,
|
||||
width: 6,
|
||||
height: 5,
|
||||
},
|
||||
queryOverrides: {
|
||||
limit: 200,
|
||||
},
|
||||
visualization: {
|
||||
slug: 'line_chart',
|
||||
type: 'LineChart',
|
||||
options: {
|
||||
xAxis: {
|
||||
name: 'Time',
|
||||
type: 'time',
|
||||
},
|
||||
yAxis: {
|
||||
name: 'Counts',
|
||||
type: 'time',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
type: 'cube_analytics',
|
||||
query: {
|
||||
measures: ['TrackedEvents.uniqueUsersCount'],
|
||||
timeDimensions: [
|
||||
{
|
||||
dimension: 'TrackedEvents.derivedTstamp',
|
||||
granularity: 'day',
|
||||
},
|
||||
],
|
||||
limit: 100,
|
||||
timezone: 'UTC',
|
||||
filters: [],
|
||||
dimensions: [],
|
||||
},
|
||||
},
|
||||
errors: null,
|
||||
__typename: 'CustomizableDashboardVisualization',
|
||||
},
|
||||
__typename: 'CustomizableDashboardPanel',
|
||||
},
|
||||
],
|
||||
__typename: 'CustomizableDashboardPanelConnection',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return newDashboard;
|
||||
};
|
||||
|
||||
export const TEST_ALL_DASHBOARDS_GRAPHQL_SUCCESS_RESPONSE = {
|
||||
data: {
|
||||
project: {
|
||||
id: 'gid://gitlab/Project/1',
|
||||
customizableDashboards: {
|
||||
nodes: [
|
||||
getGraphQLDashboard({ slug: 'audience', title: 'Audience' }, false),
|
||||
getGraphQLDashboard({ slug: 'behavior', title: 'Behavior' }, false),
|
||||
getGraphQLDashboard(
|
||||
{ slug: 'new_dashboard', title: 'new_dashboard', userDefined: true },
|
||||
false,
|
||||
),
|
||||
getGraphQLDashboard(
|
||||
{ slug: 'audience_copy', title: 'Audience (Copy)', userDefined: true },
|
||||
false,
|
||||
),
|
||||
],
|
||||
__typename: 'CustomizableDashboardConnection',
|
||||
},
|
||||
__typename: 'Project',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TEST_DASHBOARD_GRAPHQL_SUCCESS_RESPONSE = {
|
||||
data: {
|
||||
project: {
|
||||
id: 'gid://gitlab/Project/1',
|
||||
customizableDashboards: {
|
||||
nodes: [getGraphQLDashboard({ slug: 'audience', title: 'Audience' })],
|
||||
__typename: 'CustomizableDashboardConnection',
|
||||
},
|
||||
__typename: 'Project',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TEST_CUSTOM_DASHBOARD_GRAPHQL_SUCCESS_RESPONSE = {
|
||||
data: {
|
||||
project: {
|
||||
id: 'gid://gitlab/Project/1',
|
||||
customizableDashboards: {
|
||||
nodes: [
|
||||
getGraphQLDashboard({
|
||||
slug: 'custom_dashboard',
|
||||
title: 'Custom Dashboard',
|
||||
userDefined: true,
|
||||
}),
|
||||
],
|
||||
__typename: 'CustomizableDashboardConnection',
|
||||
},
|
||||
__typename: 'Project',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,339 @@
|
|||
import { nextTick } from 'vue';
|
||||
import {
|
||||
GlDisclosureDropdown,
|
||||
GlDisclosureDropdownItem,
|
||||
GlLoadingIcon,
|
||||
GlPopover,
|
||||
GlIcon,
|
||||
GlLink,
|
||||
GlSprintf,
|
||||
} from '@gitlab/ui';
|
||||
import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import PanelsBase from '~/vue_shared/components/customizable_dashboard/panels_base.vue';
|
||||
import { VARIANT_DANGER, VARIANT_WARNING, VARIANT_INFO } from '~/alert';
|
||||
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
|
||||
import { PANEL_POPOVER_DELAY } from '~/vue_shared/components/customizable_dashboard/constants';
|
||||
|
||||
describe('PanelsBase', () => {
|
||||
/** @type {import('helpers/vue_test_utils_helper').ExtendedWrapper} */
|
||||
let wrapper;
|
||||
|
||||
const createWrapper = ({ props = {}, slots = {}, mountFn = shallowMountExtended } = {}) => {
|
||||
wrapper = mountFn(PanelsBase, {
|
||||
propsData: {
|
||||
...props,
|
||||
},
|
||||
slots,
|
||||
stubs: { GlSprintf },
|
||||
});
|
||||
};
|
||||
|
||||
const findPanelTitle = () => wrapper.findComponent(TooltipOnTruncate);
|
||||
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
|
||||
const findLoadingDelayedIndicator = () => wrapper.findByTestId('panel-loading-delayed-indicator');
|
||||
const findPanelTitleTooltipIcon = () => wrapper.findByTestId('panel-title-tooltip-icon');
|
||||
const findPanelTitleAlertIcon = () => wrapper.findByTestId('panel-title-alert-icon');
|
||||
const findPanelTitlePopover = () => wrapper.findByTestId('panel-title-popover');
|
||||
const findPanelTitlePopoverLink = () => findPanelTitlePopover().findComponent(GlLink);
|
||||
const findPanelAlertPopover = () => wrapper.findComponent(GlPopover);
|
||||
const findPanelActionsDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
|
||||
const findDropdownItemByText = (text) =>
|
||||
findPanelActionsDropdown()
|
||||
.findAllComponents(GlDisclosureDropdownItem)
|
||||
.filter((w) => w.text() === text)
|
||||
.at(0);
|
||||
|
||||
describe('default behaviour', () => {
|
||||
beforeEach(() => {
|
||||
createWrapper();
|
||||
});
|
||||
|
||||
it('does not render a title', () => {
|
||||
expect(findPanelTitle().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not render a loading icon', () => {
|
||||
expect(findLoadingIcon().exists()).toBe(false);
|
||||
expect(findLoadingDelayedIndicator().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not render a disclosure dropdown', () => {
|
||||
expect(findPanelActionsDropdown().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not render an error popover', () => {
|
||||
expect(findPanelAlertPopover().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not render the tooltip icon', () => {
|
||||
expect(findPanelTitleTooltipIcon().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not set an alert border color', () => {
|
||||
const alertClasses = [
|
||||
'gl-border-t-red-500',
|
||||
'gl-border-t-orange-500',
|
||||
'gl-border-t-blue-500',
|
||||
];
|
||||
|
||||
alertClasses.forEach((alertClass) => {
|
||||
expect(wrapper.attributes('class')).not.toContain(alertClass);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a body slot', () => {
|
||||
beforeEach(() => {
|
||||
createWrapper({
|
||||
slots: {
|
||||
body: '<div data-testid="panel-body-slot"></div>',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the panel body', () => {
|
||||
expect(wrapper.findByTestId('panel-body-slot').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when loading', () => {
|
||||
beforeEach(() => {
|
||||
createWrapper({
|
||||
props: {
|
||||
loading: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a loading icon', () => {
|
||||
expect(findLoadingIcon().exists()).toBe(true);
|
||||
expect(findLoadingDelayedIndicator().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('renders the additional "Still loading" indicator if the data source is slow', async () => {
|
||||
await wrapper.setProps({ loadingDelayed: true });
|
||||
await nextTick();
|
||||
|
||||
expect(findLoadingIcon().exists()).toBe(true);
|
||||
expect(findLoadingDelayedIndicator().exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when loading with a body slot', () => {
|
||||
beforeEach(() => {
|
||||
createWrapper({
|
||||
props: {
|
||||
loading: true,
|
||||
},
|
||||
slots: {
|
||||
body: '<div data-testid="panel-body-slot"></div>',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render the panel body', () => {
|
||||
expect(wrapper.findByTestId('panel-body-slot').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when there is a title', () => {
|
||||
beforeEach(() => {
|
||||
createWrapper({
|
||||
props: {
|
||||
title: 'Panel Title',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the panel title', () => {
|
||||
expect(findPanelTitle().text()).toBe('Panel Title');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when there is a title with a tooltip', () => {
|
||||
describe('with description and link', () => {
|
||||
beforeEach(() => {
|
||||
createWrapper({
|
||||
props: {
|
||||
title: 'Panel Title',
|
||||
tooltip: {
|
||||
description: 'This is just information, %{linkStart}learn more%{linkEnd}',
|
||||
descriptionLink: '/foo',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the panel title tooltip icon with special content', () => {
|
||||
expect(findPanelTitleTooltipIcon().exists()).toBe(true);
|
||||
expect(findPanelTitlePopover().text()).toBe('This is just information, learn more');
|
||||
expect(findPanelTitlePopoverLink().attributes('href')).toBe('/foo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('without description link', () => {
|
||||
beforeEach(() => {
|
||||
createWrapper({
|
||||
props: {
|
||||
title: 'Panel Title',
|
||||
tooltip: {
|
||||
description: 'This is just information.',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the panel title tooltip icon with description only', () => {
|
||||
expect(findPanelTitleTooltipIcon().exists()).toBe(true);
|
||||
expect(findPanelTitlePopoverLink().exists()).toBe(false);
|
||||
expect(findPanelTitlePopover().text()).toBe('This is just information.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('without description', () => {
|
||||
beforeEach(() => {
|
||||
createWrapper({
|
||||
props: {
|
||||
title: 'Panel Title',
|
||||
tooltip: {
|
||||
descriptionLink: '/foo',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render the panel title tooltip icon', () => {
|
||||
expect(findPanelTitleTooltipIcon().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when there is a title with an error alert', () => {
|
||||
beforeEach(() => {
|
||||
createWrapper({
|
||||
props: {
|
||||
title: 'Panel Title',
|
||||
showAlertState: true,
|
||||
alertVariant: VARIANT_DANGER,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the panel title error icon', () => {
|
||||
expect(findPanelTitleAlertIcon().exists()).toBe(true);
|
||||
expect(findPanelTitleAlertIcon().attributes('name')).toBe('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when editing and there are actions', () => {
|
||||
const actions = [
|
||||
{
|
||||
icon: 'pencil',
|
||||
text: 'Edit',
|
||||
action: () => {},
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
createWrapper({
|
||||
props: {
|
||||
editing: true,
|
||||
actions,
|
||||
},
|
||||
mountFn: mountExtended,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the panel actions dropdown', () => {
|
||||
expect(findPanelActionsDropdown().props('items')).toStrictEqual(actions);
|
||||
});
|
||||
|
||||
it('renders the panel action dropdown item and icon', () => {
|
||||
const dropdownItem = findDropdownItemByText(actions[0].text);
|
||||
|
||||
expect(dropdownItem.exists()).toBe(true);
|
||||
expect(dropdownItem.findComponent(GlIcon).props('name')).toBe(actions[0].icon);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when there is an error alert title and the alert state is true', () => {
|
||||
beforeEach(() => {
|
||||
createWrapper({
|
||||
props: {
|
||||
alertPopoverTitle: 'Some error',
|
||||
showAlertState: true,
|
||||
},
|
||||
slots: {
|
||||
'alert-popover': '<div data-testid="alert-popover-slot"></div>',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the error popover', () => {
|
||||
const popover = findPanelAlertPopover();
|
||||
expect(popover.exists()).toBe(true);
|
||||
expect(popover.props('title')).toBe('Some error');
|
||||
|
||||
// TODO: Replace with .props() once GitLab-UI adds all supported props.
|
||||
// https://gitlab.com/gitlab-org/gitlab-ui/-/issues/428
|
||||
expect(popover.vm.$attrs.delay).toStrictEqual(PANEL_POPOVER_DELAY);
|
||||
});
|
||||
|
||||
it('renders the error popover slot', () => {
|
||||
expect(wrapper.findByTestId('alert-popover-slot').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the editing and error state are true', () => {
|
||||
beforeEach(() => {
|
||||
createWrapper({
|
||||
props: {
|
||||
showAlertState: true,
|
||||
editing: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the error popover when the dropdown is shown', async () => {
|
||||
expect(findPanelAlertPopover().exists()).toBe(true);
|
||||
|
||||
await findPanelActionsDropdown().vm.$emit('shown');
|
||||
|
||||
expect(findPanelAlertPopover().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Alert variants', () => {
|
||||
describe.each`
|
||||
alertVariant | borderColor | iconName | iconColor
|
||||
${VARIANT_DANGER} | ${'gl-border-t-red-500'} | ${'error'} | ${'gl-text-red-500'}
|
||||
${VARIANT_WARNING} | ${'gl-border-t-orange-500'} | ${'warning'} | ${'gl-text-orange-500'}
|
||||
${VARIANT_INFO} | ${'gl-border-t-blue-500'} | ${'information-o'} | ${'gl-text-blue-500'}
|
||||
`('when the alert is $alertVariant', ({ alertVariant, borderColor, iconName, iconColor }) => {
|
||||
beforeEach(() => {
|
||||
createWrapper({
|
||||
props: {
|
||||
title: 'Panel title',
|
||||
alertPopoverTitle: 'Some error',
|
||||
showAlertState: true,
|
||||
alertVariant,
|
||||
},
|
||||
slots: {
|
||||
'alert-popover': '<div data-testid="alert-popover-slot"></div>',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('sets the panel colors', () => {
|
||||
['gl-border-t-2', 'gl-border-t-solid', borderColor].forEach((cssClass) => {
|
||||
expect(wrapper.attributes('class')).toContain(cssClass);
|
||||
});
|
||||
});
|
||||
|
||||
it('sets the alert icon', () => {
|
||||
expect(findPanelTitleAlertIcon().attributes('name')).toBe(iconName);
|
||||
expect(findPanelTitleAlertIcon().attributes('class')).toContain(iconColor);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,462 @@
|
|||
import getCustomizableDashboardQuery from '~/vue_shared/components/customizable_dashboard/graphql/queries/get_customizable_dashboard.query.graphql';
|
||||
import getAllCustomizableDashboardsQuery from '~/vue_shared/components/customizable_dashboard/graphql/queries/get_all_customizable_dashboards.query.graphql';
|
||||
import {
|
||||
buildDefaultDashboardFilters,
|
||||
dateRangeOptionToFilter,
|
||||
filtersToQueryParams,
|
||||
getDateRangeOption,
|
||||
isEmptyPanelData,
|
||||
availableVisualizationsValidator,
|
||||
getDashboardConfig,
|
||||
updateApolloCache,
|
||||
getVisualizationCategory,
|
||||
parsePanelToGridItem,
|
||||
createNewVisualizationPanel,
|
||||
} from '~/vue_shared/components/customizable_dashboard/utils';
|
||||
import { newDate } from '~/lib/utils/datetime_utility';
|
||||
import {
|
||||
CUSTOM_DATE_RANGE_KEY,
|
||||
DATE_RANGE_OPTIONS,
|
||||
DEFAULT_SELECTED_OPTION_INDEX,
|
||||
} from '~/vue_shared/components/customizable_dashboard/filters/constants';
|
||||
import { createMockClient } from 'helpers/mock_apollo_helper';
|
||||
import {
|
||||
CATEGORY_SINGLE_STATS,
|
||||
CATEGORY_CHARTS,
|
||||
CATEGORY_TABLES,
|
||||
DASHBOARD_SCHEMA_VERSION,
|
||||
} from '~/vue_shared/components/customizable_dashboard/constants';
|
||||
|
||||
import {
|
||||
mockDateRangeFilterChangePayload,
|
||||
dashboard,
|
||||
mockPanel,
|
||||
TEST_VISUALIZATION,
|
||||
TEST_CUSTOM_DASHBOARDS_PROJECT,
|
||||
TEST_ALL_DASHBOARDS_GRAPHQL_SUCCESS_RESPONSE,
|
||||
getGraphQLDashboard,
|
||||
TEST_DASHBOARD_GRAPHQL_SUCCESS_RESPONSE,
|
||||
TEST_CUSTOM_DASHBOARD_GRAPHQL_SUCCESS_RESPONSE,
|
||||
} from './mock_data';
|
||||
|
||||
const option = DATE_RANGE_OPTIONS[0];
|
||||
|
||||
describe('#createNewVisualizationPanel', () => {
|
||||
it('returns the expected object', () => {
|
||||
const visualization = TEST_VISUALIZATION();
|
||||
expect(createNewVisualizationPanel(visualization)).toMatchObject({
|
||||
visualization: {
|
||||
...visualization,
|
||||
errors: null,
|
||||
},
|
||||
title: 'Test visualization',
|
||||
gridAttributes: {
|
||||
width: 4,
|
||||
height: 3,
|
||||
},
|
||||
options: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDateRangeOption', () => {
|
||||
it('should return the date range option', () => {
|
||||
expect(getDateRangeOption(option.key)).toStrictEqual(option);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dateRangeOptionToFilter', () => {
|
||||
it('filters data by `name` for the provided search term', () => {
|
||||
expect(dateRangeOptionToFilter(option)).toStrictEqual({
|
||||
startDate: option.startDate,
|
||||
endDate: option.endDate,
|
||||
dateRangeOption: option.key,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDefaultDashboardFilters', () => {
|
||||
it('returns the default option for an empty query string', () => {
|
||||
const defaultOption = DATE_RANGE_OPTIONS[DEFAULT_SELECTED_OPTION_INDEX];
|
||||
|
||||
expect(buildDefaultDashboardFilters('')).toStrictEqual({
|
||||
startDate: defaultOption.startDate,
|
||||
endDate: defaultOption.endDate,
|
||||
dateRangeOption: defaultOption.key,
|
||||
filterAnonUsers: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the option that matches the date_range_option', () => {
|
||||
const queryString = `date_range_option=${option.key}`;
|
||||
|
||||
expect(buildDefaultDashboardFilters(queryString)).toStrictEqual({
|
||||
startDate: option.startDate,
|
||||
endDate: option.endDate,
|
||||
dateRangeOption: option.key,
|
||||
filterAnonUsers: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the a custom range when the query string is custom and contains dates', () => {
|
||||
const queryString = `date_range_option=${CUSTOM_DATE_RANGE_KEY}&start_date=2023-01-10&end_date=2023-02-08`;
|
||||
|
||||
expect(buildDefaultDashboardFilters(queryString)).toStrictEqual({
|
||||
startDate: newDate('2023-01-10'),
|
||||
endDate: newDate('2023-02-08'),
|
||||
dateRangeOption: CUSTOM_DATE_RANGE_KEY,
|
||||
filterAnonUsers: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the option that matches the date_range_option and ignores the query dates when the option is not custom', () => {
|
||||
const queryString = `date_range_option=${option.key}&start_date=2023-01-10&end_date=2023-02-08`;
|
||||
|
||||
expect(buildDefaultDashboardFilters(queryString)).toStrictEqual({
|
||||
startDate: option.startDate,
|
||||
endDate: option.endDate,
|
||||
dateRangeOption: option.key,
|
||||
filterAnonUsers: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns "filterAnonUsers=true" when the query param for filtering out anonymous users is true', () => {
|
||||
const queryString = 'filter_anon_users=true';
|
||||
|
||||
expect(buildDefaultDashboardFilters(queryString)).toMatchObject({
|
||||
filterAnonUsers: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('filtersToQueryParams', () => {
|
||||
const customOption = {
|
||||
...mockDateRangeFilterChangePayload,
|
||||
dateRangeOption: CUSTOM_DATE_RANGE_KEY,
|
||||
};
|
||||
|
||||
const nonCustomOption = {
|
||||
...mockDateRangeFilterChangePayload,
|
||||
dateRangeOption: 'foobar',
|
||||
};
|
||||
|
||||
it('returns the dateRangeOption with null date params when the option is not custom', () => {
|
||||
expect(filtersToQueryParams(nonCustomOption)).toStrictEqual({
|
||||
date_range_option: 'foobar',
|
||||
end_date: null,
|
||||
start_date: null,
|
||||
filter_anon_users: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the dateRangeOption and date params when the option is custom', () => {
|
||||
expect(filtersToQueryParams(customOption)).toStrictEqual({
|
||||
date_range_option: CUSTOM_DATE_RANGE_KEY,
|
||||
start_date: '2016-01-01',
|
||||
end_date: '2016-02-01',
|
||||
filter_anon_users: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns "filter_anon_users=true" when filtering out anonymous users', () => {
|
||||
expect(filtersToQueryParams({ filterAnonUsers: true })).toMatchObject({
|
||||
filter_anon_users: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEmptyPanelData', () => {
|
||||
it.each`
|
||||
visualizationType | value | expected
|
||||
${'SingleStat'} | ${[]} | ${false}
|
||||
${'SingleStat'} | ${1} | ${false}
|
||||
${'LineChart'} | ${[]} | ${true}
|
||||
${'LineChart'} | ${[1]} | ${false}
|
||||
`(
|
||||
'returns $expected for visualization "$visualizationType" with value "$value"',
|
||||
({ visualizationType, value, expected }) => {
|
||||
const result = isEmptyPanelData(visualizationType, value);
|
||||
expect(result).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('availableVisualizationsValidator', () => {
|
||||
it('returns true when the object contains all properties', () => {
|
||||
const result = availableVisualizationsValidator({
|
||||
loading: false,
|
||||
hasError: false,
|
||||
visualizations: [],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ visualizations: [] },
|
||||
{ hasError: false },
|
||||
{ loading: true },
|
||||
{ loading: true, hasError: false },
|
||||
])('returns false when the object does not contain all properties', (testCase) => {
|
||||
const result = availableVisualizationsValidator(testCase);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDashboardConfig', () => {
|
||||
it('maps dashboard to expected value', () => {
|
||||
const result = getDashboardConfig(dashboard);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
id: 'analytics_overview',
|
||||
version: DASHBOARD_SCHEMA_VERSION,
|
||||
panels: [
|
||||
{
|
||||
gridAttributes: {
|
||||
height: 3,
|
||||
width: 3,
|
||||
},
|
||||
queryOverrides: {},
|
||||
title: 'Test A',
|
||||
visualization: 'cube_line_chart',
|
||||
},
|
||||
{
|
||||
gridAttributes: {
|
||||
height: 4,
|
||||
width: 2,
|
||||
},
|
||||
queryOverrides: {
|
||||
limit: 200,
|
||||
},
|
||||
title: 'Test B',
|
||||
visualization: 'cube_line_chart',
|
||||
},
|
||||
],
|
||||
title: 'Analytics Overview',
|
||||
status: null,
|
||||
errors: null,
|
||||
});
|
||||
});
|
||||
|
||||
['userDefined', 'slug'].forEach((omitted) => {
|
||||
it(`omits "${omitted}" dashboard property`, () => {
|
||||
const result = getDashboardConfig(dashboard);
|
||||
|
||||
expect(result[omitted]).not.toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateApolloCache', () => {
|
||||
let apolloClient;
|
||||
let mockReadQuery;
|
||||
let mockWriteQuery;
|
||||
const dashboardSlug = 'analytics_overview';
|
||||
const { fullPath } = TEST_CUSTOM_DASHBOARDS_PROJECT;
|
||||
const isProject = true;
|
||||
|
||||
const setMockCache = (mockDashboardDetails, mockDashboardsList) => {
|
||||
mockReadQuery.mockImplementation(({ query }) => {
|
||||
if (query === getCustomizableDashboardQuery) {
|
||||
return mockDashboardDetails;
|
||||
}
|
||||
if (query === getAllCustomizableDashboardsQuery) {
|
||||
return mockDashboardsList;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
apolloClient = createMockClient();
|
||||
|
||||
mockReadQuery = jest.fn();
|
||||
mockWriteQuery = jest.fn();
|
||||
apolloClient.readQuery = mockReadQuery;
|
||||
apolloClient.writeQuery = mockWriteQuery;
|
||||
});
|
||||
|
||||
describe('dashboard details cache', () => {
|
||||
it('updates an existing dashboard', () => {
|
||||
const existingDashboard = getGraphQLDashboard(
|
||||
{
|
||||
slug: 'some_existing_dash',
|
||||
title: 'some existing title',
|
||||
},
|
||||
false,
|
||||
);
|
||||
const existingDetailsCache = {
|
||||
...TEST_CUSTOM_DASHBOARD_GRAPHQL_SUCCESS_RESPONSE.data,
|
||||
};
|
||||
existingDetailsCache.project.customizableDashboards.nodes = [existingDashboard];
|
||||
|
||||
setMockCache(existingDetailsCache, null);
|
||||
|
||||
updateApolloCache({
|
||||
apolloClient,
|
||||
slug: existingDashboard.slug,
|
||||
dashboard: {
|
||||
...existingDashboard,
|
||||
title: 'some new title',
|
||||
},
|
||||
fullPath,
|
||||
isProject,
|
||||
});
|
||||
|
||||
expect(mockWriteQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
query: getCustomizableDashboardQuery,
|
||||
data: expect.objectContaining({
|
||||
project: expect.objectContaining({
|
||||
customizableDashboards: expect.objectContaining({
|
||||
nodes: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: 'some new title',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not update for new dashboards where cache is empty', () => {
|
||||
setMockCache(null, TEST_ALL_DASHBOARDS_GRAPHQL_SUCCESS_RESPONSE.data);
|
||||
|
||||
updateApolloCache({
|
||||
apolloClient,
|
||||
slug: dashboardSlug,
|
||||
dashboard,
|
||||
fullPath,
|
||||
isProject,
|
||||
});
|
||||
|
||||
expect(mockWriteQuery).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ query: getCustomizableDashboardQuery }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dashboards list', () => {
|
||||
it('adds a new dashboard to the dashboards list', () => {
|
||||
setMockCache(null, TEST_ALL_DASHBOARDS_GRAPHQL_SUCCESS_RESPONSE.data);
|
||||
|
||||
updateApolloCache({
|
||||
apolloClient,
|
||||
slug: dashboardSlug,
|
||||
dashboard,
|
||||
fullPath,
|
||||
isProject,
|
||||
});
|
||||
|
||||
expect(mockWriteQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
query: getAllCustomizableDashboardsQuery,
|
||||
data: expect.objectContaining({
|
||||
project: expect.objectContaining({
|
||||
customizableDashboards: expect.objectContaining({
|
||||
nodes: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
slug: dashboardSlug,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('updates an existing dashboard on the dashboards list', () => {
|
||||
setMockCache(null, TEST_ALL_DASHBOARDS_GRAPHQL_SUCCESS_RESPONSE.data);
|
||||
|
||||
const existingDashboards =
|
||||
TEST_CUSTOM_DASHBOARD_GRAPHQL_SUCCESS_RESPONSE.data.project.customizableDashboards.nodes;
|
||||
|
||||
const updatedDashboard = {
|
||||
...existingDashboards.at(0),
|
||||
title: 'some new title',
|
||||
};
|
||||
|
||||
updateApolloCache({
|
||||
apolloClient,
|
||||
slug: dashboardSlug,
|
||||
dashboard: updatedDashboard,
|
||||
fullPath,
|
||||
isProject,
|
||||
});
|
||||
|
||||
expect(mockWriteQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
query: getAllCustomizableDashboardsQuery,
|
||||
data: expect.objectContaining({
|
||||
project: expect.objectContaining({
|
||||
customizableDashboards: expect.objectContaining({
|
||||
nodes: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: 'some new title',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not update dashboard list cache when it has not yet been populated', () => {
|
||||
setMockCache(TEST_DASHBOARD_GRAPHQL_SUCCESS_RESPONSE.data, null);
|
||||
|
||||
updateApolloCache({
|
||||
apolloClient,
|
||||
slug: dashboardSlug,
|
||||
dashboard,
|
||||
fullPath,
|
||||
isProject,
|
||||
});
|
||||
|
||||
expect(mockWriteQuery).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ query: getAllCustomizableDashboardsQuery }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVisualizationCategory', () => {
|
||||
it.each`
|
||||
category | type
|
||||
${CATEGORY_SINGLE_STATS} | ${'SingleStat'}
|
||||
${CATEGORY_TABLES} | ${'DataTable'}
|
||||
${CATEGORY_CHARTS} | ${'LineChart'}
|
||||
${CATEGORY_CHARTS} | ${'FooBar'}
|
||||
`('returns $category when the visualization type is $type', ({ category, type }) => {
|
||||
expect(getVisualizationCategory({ type })).toBe(category);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePanelToGridItem', () => {
|
||||
it('parses all panel configs to GridStack format', () => {
|
||||
const { gridAttributes, ...rest } = mockPanel;
|
||||
|
||||
expect(parsePanelToGridItem(mockPanel)).toStrictEqual({
|
||||
x: gridAttributes.xPos,
|
||||
y: gridAttributes.yPos,
|
||||
w: gridAttributes.width,
|
||||
h: gridAttributes.height,
|
||||
minH: gridAttributes.minHeight,
|
||||
minW: gridAttributes.minWidth,
|
||||
maxH: gridAttributes.maxHeight,
|
||||
maxW: gridAttributes.maxWidth,
|
||||
id: mockPanel.id,
|
||||
props: rest,
|
||||
});
|
||||
});
|
||||
|
||||
it('filters out props with undefined values', () => {
|
||||
const local = { ...mockPanel };
|
||||
local.id = undefined;
|
||||
|
||||
expect(Object.keys(parsePanelToGridItem(local))).not.toContain('id');
|
||||
});
|
||||
});
|
||||
|
|
@ -262,6 +262,36 @@ describe('Work item add note', () => {
|
|||
expect(clearDraft).toHaveBeenCalledWith('gid://gitlab/WorkItem/1-comment');
|
||||
});
|
||||
|
||||
it('refetches widgets when work item type is updated', async () => {
|
||||
await createComponent({
|
||||
isEditing: true,
|
||||
mutationHandler: jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
createNote: {
|
||||
note: {
|
||||
id: 'gid://gitlab/Discussion/c872ba2d7d3eb780d2255138d67ca8b04f65b122',
|
||||
discussion: {
|
||||
id: 'gid://gitlab/Discussion/c872ba2d7d3eb780d2255138d67ca8b04f65b122',
|
||||
notes: {
|
||||
nodes: [],
|
||||
__typename: 'NoteConnection',
|
||||
},
|
||||
__typename: 'Discussion',
|
||||
},
|
||||
__typename: 'Note',
|
||||
},
|
||||
__typename: 'CreateNotePayload',
|
||||
errors: ['Commands only Type changed successfully.', 'Command names ["type"]'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(workItemResponseHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits error to parent when the comment form emits error', async () => {
|
||||
await createComponent({ isEditing: true, signedIn: true });
|
||||
const error = 'error';
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Packages::Maven::FindOrCreatePackageService, feature_category: :package_registry do
|
||||
RSpec.describe Packages::Maven::FindOrCreatePackageService, :clean_gitlab_redis_shared_state, feature_category: :package_registry do
|
||||
let_it_be(:project) { create(:project) }
|
||||
let_it_be(:user) { create(:user) }
|
||||
|
||||
|
|
@ -14,7 +14,10 @@ RSpec.describe Packages::Maven::FindOrCreatePackageService, feature_category: :p
|
|||
let(:params) { { path: param_path, file_name: file_name } }
|
||||
let(:service) { described_class.new(project, user, params) }
|
||||
|
||||
it { expect(described_class).to include_module(::Gitlab::ExclusiveLeaseHelpers) }
|
||||
|
||||
describe '#execute' do
|
||||
include ExclusiveLeaseHelpers
|
||||
using RSpec::Parameterized::TableSyntax
|
||||
|
||||
subject(:execute_service) { service.execute }
|
||||
|
|
@ -218,5 +221,66 @@ RSpec.describe Packages::Maven::FindOrCreatePackageService, feature_category: :p
|
|||
"Maven metadatum app group is invalid, Maven metadatum app name can't be blank, " \
|
||||
"Maven metadatum app name is invalid, Name can't be blank, Name is invalid"
|
||||
end
|
||||
|
||||
context 'with exlusive lease guard' do
|
||||
let(:lease_key) { service.send(:lease_key) }
|
||||
|
||||
it 'obtains a lease to find or create a new package' do
|
||||
expect_to_obtain_exclusive_lease(lease_key)
|
||||
|
||||
execute_service
|
||||
end
|
||||
|
||||
context 'when the lease is already taken' do
|
||||
before do
|
||||
stub_exclusive_lease_taken(lease_key)
|
||||
end
|
||||
|
||||
it { is_expected.to be_error.and have_attributes(message: 'Failed to obtain a lock') }
|
||||
end
|
||||
|
||||
context 'when use_exclusive_lease_in_mvn_find_or_create_package feature flag is disabled' do
|
||||
before do
|
||||
stub_feature_flags(use_exclusive_lease_in_mvn_find_or_create_package: false)
|
||||
end
|
||||
|
||||
it 'does not obtain a lease' do
|
||||
expect(stub_exclusive_lease).not_to receive(:try_obtain)
|
||||
|
||||
execute_service
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with parallel execution' do
|
||||
it 'only creates one package' do
|
||||
expect do
|
||||
with_threads do
|
||||
::Gitlab::ExclusiveLease.skipping_transaction_check do
|
||||
described_class.new(project, user, params).execute
|
||||
end
|
||||
end
|
||||
end.to change { Packages::Package.maven.count }.by(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def with_threads(count: 5, &block)
|
||||
return unless block
|
||||
|
||||
# create a race condition - structure from https://blog.arkency.com/2015/09/testing-race-conditions/
|
||||
wait_for_it = true
|
||||
|
||||
threads = Array.new(count) do
|
||||
Thread.new do
|
||||
# A loop to make threads busy until we `join` them
|
||||
true while wait_for_it
|
||||
|
||||
yield
|
||||
end
|
||||
end
|
||||
|
||||
wait_for_it = false
|
||||
threads.each(&:join)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe 'shared/_broadcast_message.html.haml', feature_category: :onboarding do
|
||||
RSpec.describe 'shared/_broadcast_message.html.haml', feature_category: :notifications do
|
||||
describe 'render' do
|
||||
let(:dismissal_data) { "[data-dismissal-path=\"#{broadcast_message_dismissals_path}\"]" }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import GridstackWrapper from 'ee/vue_shared/components/customizable_dashboard/gridstack_wrapper.vue';
|
||||
import GridstackWrapper from '~/vue_shared/components/customizable_dashboard/gridstack_wrapper.vue';
|
||||
import GridstackPanel from 'storybook_helpers/dashboards/gridstack_panel.vue';
|
||||
|
||||
export default {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import PanelsBase from 'ee/vue_shared/components/customizable_dashboard/panels_base.vue';
|
||||
import PanelsBase from '~/vue_shared/components/customizable_dashboard/panels_base.vue';
|
||||
|
||||
export default {
|
||||
name: 'GridstackPanel',
|
||||
|
|
|
|||
Loading…
Reference in New Issue