Commit 3029720c authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 24423a9a c0c28d30
......@@ -62,6 +62,7 @@ export default {
v-bind="$attrs"
:class="{ 'is-active': isActive }"
class="diff-file-row"
truncate-middle
:file-classes="fileClasses"
v-on="$listeners"
>
......
import Vue from 'vue';
import PipelineSchedulesCallout from '../shared/components/pipeline_schedules_callout.vue';
document.addEventListener(
'DOMContentLoaded',
() =>
document.addEventListener('DOMContentLoaded', () => {
const el = document.getElementById('pipeline-schedules-callout');
if (!el) {
return;
}
const { docsUrl, illustrationUrl } = el.dataset;
// eslint-disable-next-line no-new
new Vue({
el: '#pipeline-schedules-callout',
components: {
'pipeline-schedules-callout': PipelineSchedulesCallout,
},
el,
render(createElement) {
return createElement('pipeline-schedules-callout');
return createElement(PipelineSchedulesCallout);
},
provide: {
docsUrl,
illustrationUrl,
},
}),
);
});
});
......@@ -14,10 +14,9 @@ export default {
components: {
GlButton,
},
inject: ['docsUrl', 'illustrationUrl'],
data() {
return {
docsUrl: document.getElementById('pipeline-schedules-callout').dataset.docsUrl,
imageUrl: document.getElementById('pipeline-schedules-callout').dataset.imageUrl,
calloutDismissed: parseBoolean(Cookies.get(cookieKey)),
};
},
......@@ -31,7 +30,7 @@ export default {
</script>
<template>
<div v-if="!calloutDismissed" class="pipeline-schedules-user-callout user-callout">
<div class="bordered-box landing content-block">
<div class="bordered-box landing content-block" data-testid="innerContent">
<gl-button
category="tertiary"
icon="close"
......@@ -39,8 +38,8 @@ export default {
class="gl-absolute gl-top-2 gl-right-2"
@click="dismissCallout"
/>
<div class="svg-container">
<img :src="imageUrl" />
<div class="svg-content">
<img :src="illustrationUrl" />
</div>
<div class="user-callout-copy">
<h4>{{ __('Scheduling Pipelines') }}</h4>
......
<script>
import { GlTruncate } from '@gitlab/ui';
import FileHeader from '~/vue_shared/components/file_row_header.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import { escapeFileUrl } from '~/lib/utils/url_utility';
......@@ -8,6 +9,7 @@ export default {
components: {
FileHeader,
FileIcon,
GlTruncate,
},
props: {
file: {
......@@ -28,6 +30,11 @@ export default {
required: false,
default: '',
},
truncateMiddle: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
isTree() {
......@@ -134,9 +141,9 @@ export default {
<span
ref="textOutput"
:style="levelIndentation"
class="file-row-name str-truncated"
class="file-row-name"
data-qa-selector="file_name_content"
:class="fileClasses"
:class="[fileClasses, { 'str-truncated': !truncateMiddle, 'gl-min-w-0': truncateMiddle }]"
>
<file-icon
class="file-row-icon"
......@@ -147,7 +154,8 @@ export default {
:opened="file.opened"
:size="16"
/>
{{ file.name }}
<gl-truncate v-if="truncateMiddle" :text="file.name" position="middle" class="gl-pr-7" />
<template v-else>{{ file.name }}</template>
</span>
<slot></slot>
</div>
......
<script>
import { truncatePathMiddleToLength } from '~/lib/utils/text_utility';
const MAX_PATH_LENGTH = 40;
import { GlTruncate } from '@gitlab/ui';
export default {
components: {
GlTruncate,
},
props: {
path: {
type: String,
required: true,
},
},
computed: {
truncatedPath() {
return truncatePathMiddleToLength(this.path, MAX_PATH_LENGTH);
},
},
};
</script>
<template>
<div class="file-row-header bg-white sticky-top p-2 js-file-row-header" :title="path">
<span class="bold">{{ truncatedPath }}</span>
<gl-truncate :text="path" position="middle" class="bold" />
</div>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import { GlDrawer, GlBadge, GlIcon, GlLink } from '@gitlab/ui';
import {
GlDrawer,
GlBadge,
GlIcon,
GlLink,
GlInfiniteScroll,
GlResizeObserverDirective,
} from '@gitlab/ui';
import SkeletonLoader from './skeleton_loader.vue';
import Tracking from '~/tracking';
import { getDrawerBodyHeight } from '../utils/get_drawer_body_height';
const trackingMixin = Tracking.mixin();
......@@ -12,8 +20,12 @@ export default {
GlBadge,
GlIcon,
GlLink,
GlInfiniteScroll,
SkeletonLoader,
},
directives: {
GlResizeObserver: GlResizeObserverDirective,
},
mixins: [trackingMixin],
props: {
storageKey: {
......@@ -23,7 +35,7 @@ export default {
},
},
computed: {
...mapState(['open', 'features']),
...mapState(['open', 'features', 'pageInfo', 'drawerBodyHeight']),
},
mounted() {
this.openDrawer(this.storageKey);
......@@ -35,20 +47,41 @@ export default {
this.track('click_whats_new_drawer', { label: 'namespace_id', value: namespaceId });
},
methods: {
...mapActions(['openDrawer', 'closeDrawer', 'fetchItems']),
...mapActions(['openDrawer', 'closeDrawer', 'fetchItems', 'setDrawerBodyHeight']),
bottomReached() {
if (this.pageInfo.nextPage) {
this.fetchItems(this.pageInfo.nextPage);
}
},
handleResize() {
const height = getDrawerBodyHeight(this.$refs.drawer.$el);
this.setDrawerBodyHeight(height);
},
},
};
</script>
<template>
<div>
<gl-drawer class="whats-new-drawer" :open="open" @close="closeDrawer">
<gl-drawer
ref="drawer"
v-gl-resize-observer="handleResize"
class="whats-new-drawer"
:open="open"
@close="closeDrawer"
>
<template #header>
<h4 class="page-title my-2">{{ __("What's new at GitLab") }}</h4>
<h4 class="page-title gl-my-3">{{ __("What's new at GitLab") }}</h4>
</template>
<div class="pb-6">
<template v-if="features">
<div v-for="feature in features" :key="feature.title" class="mb-6">
<gl-infinite-scroll
v-if="features.length"
:fetched-items="features.length"
:max-list-height="drawerBodyHeight"
class="gl-p-0"
@bottomReached="bottomReached"
>
<template #items>
<div v-for="feature in features" :key="feature.title" class="gl-mb-7 gl-px-5 gl-pt-5">
<gl-link
:href="feature.url"
target="_blank"
......@@ -60,11 +93,14 @@ export default {
<h5 class="gl-font-base">{{ feature.title }}</h5>
</gl-link>
<div v-if="feature.packages" class="gl-mb-3">
<template v-for="package_name in feature.packages">
<gl-badge :key="package_name" size="sm" class="whats-new-item-badge gl-mr-2">
<gl-badge
v-for="package_name in feature.packages"
:key="package_name"
size="sm"
class="whats-new-item-badge gl-mr-2"
>
<gl-icon name="license" />{{ package_name }}
</gl-badge>
</template>
</div>
<gl-link
:href="feature.url"
......@@ -76,7 +112,7 @@ export default {
<img
:alt="feature.title"
:src="feature.image_url"
class="img-thumbnail px-6 gl-py-3 whats-new-item-image"
class="img-thumbnail gl-px-8 gl-py-3 whats-new-item-image"
/>
</gl-link>
<p class="gl-pt-3">{{ feature.body }}</p>
......@@ -90,11 +126,11 @@ export default {
>
</div>
</template>
</gl-infinite-scroll>
<div v-else class="gl-mt-5">
<skeleton-loader />
<skeleton-loader />
</div>
</div>
</gl-drawer>
<div v-if="open" class="whats-new-modal-backdrop modal-backdrop"></div>
</div>
......
import * as types from './mutation_types';
import axios from '~/lib/utils/axios_utils';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
export default {
closeDrawer({ commit }) {
......@@ -12,9 +13,33 @@ export default {
localStorage.setItem(storageKey, JSON.stringify(false));
}
},
fetchItems({ commit }) {
return axios.get('/-/whats_new').then(({ data }) => {
commit(types.SET_FEATURES, data);
fetchItems({ commit, state }, page) {
if (state.fetching) {
return false;
}
commit(types.SET_FETCHING, true);
return axios
.get('/-/whats_new', {
params: {
page,
},
})
.then(({ data, headers }) => {
commit(types.ADD_FEATURES, data);
const normalizedHeaders = normalizeHeaders(headers);
const { nextPage } = parseIntPagination(normalizedHeaders);
commit(types.SET_PAGE_INFO, {
nextPage,
});
})
.finally(() => {
commit(types.SET_FETCHING, false);
});
},
setDrawerBodyHeight({ commit }, height) {
commit(types.SET_DRAWER_BODY_HEIGHT, height);
},
};
export const CLOSE_DRAWER = 'CLOSE_DRAWER';
export const OPEN_DRAWER = 'OPEN_DRAWER';
export const SET_FEATURES = 'SET_FEATURES';
export const ADD_FEATURES = 'ADD_FEATURES';
export const SET_PAGE_INFO = 'SET_PAGE_INFO';
export const SET_FETCHING = 'SET_FETCHING';
export const SET_DRAWER_BODY_HEIGHT = 'SET_DRAWER_BODY_HEIGHT';
......@@ -7,7 +7,16 @@ export default {
[types.OPEN_DRAWER](state) {
state.open = true;
},
[types.SET_FEATURES](state, data) {
state.features = data;
[types.ADD_FEATURES](state, data) {
state.features = state.features.concat(data);
},
[types.SET_PAGE_INFO](state, pageInfo) {
state.pageInfo = pageInfo;
},
[types.SET_FETCHING](state, fetching) {
state.fetching = fetching;
},
[types.SET_DRAWER_BODY_HEIGHT](state, height) {
state.drawerBodyHeight = height;
},
};
export default {
open: false,
features: null,
features: [],
fetching: false,
drawerBodyHeight: null,
pageInfo: {
nextPage: null,
},
};
export const getDrawerBodyHeight = drawer => {
const drawerViewableHeight = drawer.clientHeight - drawer.getBoundingClientRect().top;
const drawerHeaderHeight = drawer.querySelector('.gl-drawer-header').clientHeight;
return drawerViewableHeight - drawerHeaderHeight;
};
.whats-new-drawer {
margin-top: $header-height;
@include gl-shadow-none;
overflow-y: hidden;
.gl-infinite-scroll-legend {
@include gl-display-none;
}
}
.with-performance-bar .whats-new-drawer {
......
......@@ -5,14 +5,14 @@ class WhatsNewController < ApplicationController
skip_before_action :authenticate_user!
before_action :check_feature_flag
before_action :check_feature_flag, :check_valid_page_param, :set_pagination_headers
feature_category :navigation
def index
respond_to do |format|
format.js do
render json: whats_new_most_recent_release_items
render json: whats_new_release_items(page: current_page)
end
end
end
......@@ -22,4 +22,23 @@ class WhatsNewController < ApplicationController
def check_feature_flag
render_404 unless Feature.enabled?(:whats_new_drawer, current_user)
end
def check_valid_page_param
render_404 if current_page < 1
end
def set_pagination_headers
response.set_header('X-Next-Page', next_page)
end
def current_page
params[:page]&.to_i || 1
end
def next_page
next_page = current_page + 1
next_index = next_page - 1
next_page if whats_new_file_paths[next_index]
end
end
......@@ -5,7 +5,7 @@ module WhatsNewHelper
def whats_new_most_recent_release_items_count
Gitlab::ProcessMemoryCache.cache_backend.fetch('whats_new:release_items_count', expires_in: CACHE_DURATION) do
whats_new_most_recent_release_items&.count
whats_new_release_items&.count
end
end
......@@ -19,9 +19,7 @@ module WhatsNewHelper
def whats_new_most_recent_version
Gitlab::ProcessMemoryCache.cache_backend.fetch('whats_new:release_version', expires_in: CACHE_DURATION) do
if whats_new_most_recent_release_items
whats_new_most_recent_release_items.first.try(:[], 'release')
end
whats_new_release_items&.first&.[]('release')
end
end
end
......@@ -2,7 +2,7 @@
- page_title _("Pipeline Schedules")
- add_page_specific_style 'page_bundles/pipeline_schedules'
#pipeline-schedules-callout{ data: { docs_url: help_page_path('ci/pipelines/schedules'), image_url: image_path('illustrations/pipeline_schedule_callout.svg') } }
#pipeline-schedules-callout{ data: { docs_url: help_page_path('ci/pipelines/schedules'), illustration_url: image_path('illustrations/pipeline_schedule_callout.svg') } }
.top-area
- schedule_path_proc = ->(scope) { pipeline_schedules_path(@project, scope: scope) }
= render "tabs", schedule_path_proc: schedule_path_proc, all_schedules: @all_schedules, scope: @scope
......
---
title: Fixed long paths truncating in merge request sidebar incorrectly
merge_request: 45994
author:
type: fixed
......@@ -6,6 +6,7 @@
# For a list of all options, see https://errata-ai.gitbook.io/vale/getting-started/styles
extends: substitution
message: "CAUTION: alert boxes must be of the format 'CAUTION: **Caution:**'. 'Caution' can be replaced with 'Warning' or 'Important'."
link: https://docs.gitlab.com/ee/development/documentation/styleguide.html#alert-boxes
level: warning
nonword: true
scope: raw
......
......@@ -6,6 +6,7 @@
# For a list of all options, see https://errata-ai.gitbook.io/vale/getting-started/styles
extends: substitution
message: "DANGER: alert boxes must be of the format 'DANGER: **Warning:**'. 'Warning' can be replaced with 'Important', 'Deprecated', or 'Required'."
link: https://docs.gitlab.com/ee/development/documentation/styleguide.html#alert-boxes
level: error
nonword: true
scope: raw
......
......@@ -6,6 +6,7 @@
# For a list of all options, see https://errata-ai.gitbook.io/vale/getting-started/styles
extends: substitution
message: "NOTE: and TIP: alert boxes must be of the format 'NOTE: **Note:**' or 'TIP: **Tip:**"
link: https://docs.gitlab.com/ee/development/documentation/styleguide.html#alert-boxes
level: warning
nonword: true
scope: raw
......
......@@ -5,7 +5,7 @@
#
# For a list of all options, see https://errata-ai.gitbook.io/vale/getting-started/styles
extends: existence
message: 'Avoid words like "%s" that promise future changes.'
message: 'Avoid words like "%s" that promise future changes, because documentation is about the current state of the product.'
level: suggestion
ignorecase: true
link: https://docs.gitlab.com/ee/development/documentation/styleguide.html#language-to-avoid
......
......@@ -6,7 +6,7 @@
#
# For a list of all options, see https://errata-ai.gitbook.io/vale/getting-started/styles
extends: existence
message: 'Avoid using future tense: "%s"'
message: 'Avoid using future tense: "%s". Use present tense instead.'
ignorecase: true
level: warning
link: https://docs.gitlab.com/ee/development/documentation/styleguide.html#language-to-avoid
......
......@@ -5,7 +5,7 @@
#
# For a list of all options, see https://errata-ai.gitbook.io/vale/getting-started/styles
extends: existence
message: 'Link "%s" must be relative.'
message: 'Link "%s" must be a relative link with a .md extension.'
link: https://docs.gitlab.com/ee/development/documentation/styleguide.html#links-to-internal-documentation
level: error
scope: raw
......
---
# Error: gitlab.VersionText
#
# Checks that version text is formatted correctly.
#
# Specifically looks for either of the following that is immediately followed on the next line
# by content, which will break rendering:
#
# - `> Introduced` (version text without a link)
# - `> [Introduced` (version text with a link)
#
# Because it excludes `-`, it doesn't look for multi-line version text, for which content
# immediately on the next line is ok. However, this will often highlight where multi-line version
# text is attempted without `-` characters.
# Checks for use of some of the top misused terms at GitLab.
# For substitutions only flagged as warnings, see SubstitutionWarning.yml
#
# For a list of all options, see https://errata-ai.gitbook.io/vale/getting-started/styles
extends: existence
message: '"%s" is not formatted correctly.'
message: 'This introduced-in line is not formatted correctly.'
link: https://docs.gitlab.com/ee/development/documentation/styleguide.html#text-for-documentation-requiring-version-text
level: error
scope: raw
......
......@@ -2,27 +2,39 @@
module Gitlab
module WhatsNew
CACHE_DURATION = 1.day
CACHE_DURATION = 1.hour
WHATS_NEW_FILES_PATH = Rails.root.join('data', 'whats_new', '*.yml')
private
def whats_new_most_recent_release_items
Rails.cache.fetch('whats_new:release_items', expires_in: CACHE_DURATION) do
file = File.read(most_recent_release_file_path)
def whats_new_release_items(page: 1)
Rails.cache.fetch(whats_new_items_cache_key(page), expires_in: CACHE_DURATION) do
index = page - 1
file_path = whats_new_file_paths[index]
next if file_path.nil?
file = File.read(file_path)
items = YAML.safe_load(file, permitted_classes: [Date])
items if items.is_a?(Array)
end
rescue => e
Gitlab::ErrorTracking.track_exception(e, yaml_file_path: most_recent_release_file_path)
Gitlab::ErrorTracking.track_exception(e, page: page)
nil
end
def most_recent_release_file_path
@most_recent_release_file_path ||= Dir.glob(WHATS_NEW_FILES_PATH).max
def whats_new_file_paths
@whats_new_file_paths ||= Rails.cache.fetch('whats_new:file_paths', expires_in: CACHE_DURATION) do
Dir.glob(WHATS_NEW_FILES_PATH).sort.reverse
end
end
def whats_new_items_cache_key(page)
filename = /\d*\_\d*\_\d*/.match(whats_new_file_paths&.first)
"whats_new:release_items:file-#{filename}:page-#{page}"
end
end
end
......@@ -91,12 +91,12 @@ describe('Diffs tree list component', () => {
expect(
getFileRows()
.at(0)
.text(),
.html(),
).toContain('index.js');
expect(
getFileRows()
.at(1)
.text(),
.html(),
).toContain('app');
});
......@@ -138,7 +138,7 @@ describe('Diffs tree list component', () => {
wrapper.vm.$store.state.diffs.renderTreeList = false;
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find('.file-row').text()).toContain('index.js');
expect(wrapper.find('.file-row').html()).toContain('index.js');
});
});
});
......
import Vue from 'vue';
import { shallowMount } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import Cookies from 'js-cookie';
import { getByRole } from '@testing-library/dom';
import PipelineSchedulesCallout from '~/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue';
const PipelineSchedulesCalloutComponent = Vue.extend(PipelineSchedulesCallout);
const cookieKey = 'pipeline_schedules_callout_dismissed';
const docsUrl = 'help/ci/scheduled_pipelines';
const imageUrl = 'pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg';
const illustrationUrl = 'pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg';
describe('Pipeline Schedule Callout', () => {
let calloutComponent;
let wrapper;
beforeEach(() => {
setFixtures(`
<div id='pipeline-schedules-callout' data-docs-url=${docsUrl} data-image-url=${imageUrl}></div>
`);
});
describe('independent of cookies', () => {
beforeEach(() => {
calloutComponent = new PipelineSchedulesCalloutComponent().$mount();
const createComponent = () => {
wrapper = shallowMount(PipelineSchedulesCallout, {
provide: {
docsUrl,
illustrationUrl,
},
});
};
it('the component can be initialized', () => {
expect(calloutComponent).toBeDefined();
});
it('correctly sets docsUrl', () => {
expect(calloutComponent.docsUrl).toContain(docsUrl);
});
it('correctly sets imageUrl', () => {
expect(calloutComponent.imageUrl).toContain(imageUrl);
});
});
const findInnerContentOfCallout = () => wrapper.find('[data-testid="innerContent"]');
const findDismissCalloutBtn = () => wrapper.find(GlButton);
describe(`when ${cookieKey} cookie is set`, () => {
beforeEach(() => {
beforeEach(async () => {
Cookies.set(cookieKey, true);
calloutComponent = new PipelineSchedulesCalloutComponent().$mount();
createComponent();
await wrapper.vm.$nextTick();
});
it('correctly sets calloutDismissed to true', () => {
expect(calloutComponent.calloutDismissed).toBe(true);
afterEach(() => {
wrapper.destroy();
});
it('does not render the callout', () => {
expect(calloutComponent.$el.childNodes.length).toBe(0);
expect(findInnerContentOfCallout().exists()).toBe(false);
});
});
describe('when cookie is not set', () => {
beforeEach(() => {
Cookies.remove(cookieKey);
calloutComponent = new PipelineSchedulesCalloutComponent().$mount();
createComponent();
});
it('correctly sets calloutDismissed to false', () => {
expect(calloutComponent.calloutDismissed).toBe(false);
afterEach(() => {
wrapper.destroy();
});
it('renders the callout container', () => {
expect(calloutComponent.$el.querySelector('.bordered-box')).not.toBeNull();
});
it('renders the callout img', () => {
expect(calloutComponent.$el.outerHTML).toContain('<img');
expect(findInnerContentOfCallout().exists()).toBe(true);
});
it('renders the callout title', () => {
expect(calloutComponent.$el.outerHTML).toContain('Scheduling Pipelines');
expect(wrapper.find('h4').text()).toBe('Scheduling Pipelines');
});
it('renders the callout text', () => {
expect(calloutComponent.$el.outerHTML).toContain('runs pipelines in the future');
expect(wrapper.find('p').text()).toContain('runs pipelines in the future');
});
it('renders the documentation url', () => {
expect(calloutComponent.$el.outerHTML).toContain(docsUrl);
expect(wrapper.find('a').attributes('href')).toBe(docsUrl);
});
it('updates calloutDismissed when close button is clicked', done => {
getByRole(calloutComponent.$el, 'button', /dismiss/i).click();
describe('methods', () => {
it('#dismissCallout sets calloutDismissed to true', async () => {
expect(wrapper.vm.calloutDismissed).toBe(false);
Vue.nextTick(() => {
expect(calloutComponent.calloutDismissed).toBe(true);
done();
});
findDismissCalloutBtn().vm.$emit('click');
await wrapper.vm.$nextTick();
expect(findInnerContentOfCallout().exists()).toBe(false);
});
it('#dismissCallout updates calloutDismissed', done => {
calloutComponent.dismissCallout();
it('sets cookie on dismiss', () => {
const setCookiesSpy = jest.spyOn(Cookies, 'set');
findDismissCalloutBtn().vm.$emit('click');
Vue.nextTick(() => {
expect(calloutComponent.calloutDismissed).toBe(true);
done();
expect(setCookiesSpy).toHaveBeenCalledWith('pipeline_schedules_callout_dismissed', true, {
expires: 365,
});
});
});
it('is hidden when close button is clicked', done => {
getByRole(calloutComponent.$el, 'button', /dismiss/i).click();
it('is hidden when close button is clicked', async () => {
findDismissCalloutBtn().vm.$emit('click');
Vue.nextTick(() => {
expect(calloutComponent.$el.childNodes.length).toBe(0);
done();
});
await wrapper.vm.$nextTick();
expect(findInnerContentOfCallout().exists()).toBe(false);
});
});
});
......@@ -5,11 +5,11 @@ exports[`File row header component adds multiple ellipsises after 40 characters
class="file-row-header bg-white sticky-top p-2 js-file-row-header"
title="app/assets/javascripts/merge_requests/widget/diffs/notes"
>
<span
<gl-truncate-stub
class="bold"
>
app/assets/javascripts/…/…/diffs/notes
</span>
position="middle"
text="app/assets/javascripts/merge_requests/widget/diffs/notes"
/>
</div>
`;
......@@ -18,11 +18,11 @@ exports[`File row header component renders file path 1`] = `
class="file-row-header bg-white sticky-top p-2 js-file-row-header"
title="app/assets"
>
<span
<gl-truncate-stub
class="bold"
>
app/assets
</span>
position="middle"
text="app/assets"
/>
</div>
`;
......@@ -31,10 +31,10 @@ exports[`File row header component trucates path after 40 characters 1`] = `
class="file-row-header bg-white sticky-top p-2 js-file-row-header"
title="app/assets/javascripts/merge_requests"
>
<span
<gl-truncate-stub
class="bold"
>
app/assets/javascripts/merge_requests
</span>
position="middle"
text="app/assets/javascripts/merge_requests"
/>
</div>
`;
import { createLocalVue, mount } from '@vue/test-utils';
import Vuex from 'vuex';
import { GlDrawer } from '@gitlab/ui';
import { GlDrawer, GlInfiniteScroll } from '@gitlab/ui';
import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import App from '~/whats_new/components/app.vue';
import { getDrawerBodyHeight } from '~/whats_new/utils/get_drawer_body_height';
const MOCK_DRAWER_BODY_HEIGHT = 42;
jest.mock('~/whats_new/utils/get_drawer_body_height', () => ({
getDrawerBodyHeight: jest.fn().mockImplementation(() => MOCK_DRAWER_BODY_HEIGHT),
}));
const localVue = createLocalVue();
localVue.use(Vuex);
......@@ -20,11 +28,13 @@ describe('App', () => {
openDrawer: jest.fn(),
closeDrawer: jest.fn(),
fetchItems: jest.fn(),
setDrawerBodyHeight: jest.fn(),
};
state = {
open: true,
features: null,
features: [],
drawerBodyHeight: null,
};
store = new Vuex.Store({
......@@ -36,9 +46,15 @@ describe('App', () => {
localVue,
store,
propsData,
directives: {
GlResizeObserver: createMockDirective(),
},
});
};
const findInfiniteScroll = () => wrapper.find(GlInfiniteScroll);
const emitBottomReached = () => findInfiniteScroll().vm.$emit('bottomReached');
beforeEach(async () => {
document.body.dataset.page = 'test-page';
document.body.dataset.namespaceId = 'namespace-840';
......@@ -47,6 +63,7 @@ describe('App', () => {
buildWrapper();
wrapper.vm.$store.state.features = [{ title: 'Whats New Drawer', url: 'www.url.com' }];
wrapper.vm.$store.state.drawerBodyHeight = MOCK_DRAWER_BODY_HEIGHT;
await wrapper.vm.$nextTick();
});
......@@ -61,7 +78,7 @@ describe('App', () => {
expect(getDrawer().exists()).toBe(true);
});
it('dispatches openDrawer when mounted', () => {
it('dispatches openDrawer and tracking calls when mounted', () => {
expect(actions.openDrawer).toHaveBeenCalledWith(expect.any(Object), 'storage-key');
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_whats_new_drawer', {
label: 'namespace_id',
......@@ -102,4 +119,46 @@ describe('App', () => {
},
]);
});
it('renders infinite scroll', () => {
const scroll = findInfiniteScroll();
expect(scroll.props()).toMatchObject({
fetchedItems: wrapper.vm.$store.state.features.length,
maxListHeight: MOCK_DRAWER_BODY_HEIGHT,
});
});
describe('bottomReached', () => {
beforeEach(() => {
actions.fetchItems.mockClear();
});
it('when nextPage exists it calls fetchItems', () => {
wrapper.vm.$store.state.pageInfo = { nextPage: 840 };
emitBottomReached();
expect(actions.fetchItems).toHaveBeenCalledWith(expect.anything(), 840);
});
it('when nextPage does not exist it does not call fetchItems', () => {
wrapper.vm.$store.state.pageInfo = { nextPage: null };
emitBottomReached();
expect(actions.fetchItems).not.toHaveBeenCalled();
});
});
it('calls getDrawerBodyHeight and setDrawerBodyHeight when resize directive is triggered', () => {
const { value } = getBinding(getDrawer().element, 'gl-resize-observer');
value();
expect(getDrawerBodyHeight).toHaveBeenCalledWith(wrapper.find(GlDrawer).element);
expect(actions.setDrawerBodyHeight).toHaveBeenCalledWith(
expect.any(Object),
MOCK_DRAWER_BODY_HEIGHT,
);
});
});
......@@ -30,7 +30,9 @@ describe('whats new actions', () => {
axiosMock = new MockAdapter(axios);
axiosMock
.onGet('/-/whats_new')
.replyOnce(200, [{ title: 'Whats New Drawer', url: 'www.url.com' }]);
.replyOnce(200, [{ title: 'Whats New Drawer', url: 'www.url.com' }], {
'x-next-page': '2',
});
await waitForPromises();
});
......@@ -39,10 +41,23 @@ describe('whats new actions', () => {
axiosMock.restore();
});
it('should commit setFeatures', () => {
it('if already fetching, does not fetch', () => {
testAction(actions.fetchItems, {}, { fetching: true }, []);
});
it('should commit fetching, setFeatures and setPagination', () => {
testAction(actions.fetchItems, {}, {}, [
{ type: types.SET_FEATURES, payload: [{ title: 'Whats New Drawer', url: 'www.url.com' }] },
{ type: types.SET_FETCHING, payload: true },
{ type: types.ADD_FEATURES, payload: [{ title: 'Whats New Drawer', url: 'www.url.com' }] },
{ type: types.SET_PAGE_INFO, payload: { nextPage: 2 } },
{ type: types.SET_FETCHING, payload: false },
]);
});
});
describe('setDrawerBodyHeight', () => {
testAction(actions.setDrawerBodyHeight, 42, {}, [
{ type: types.SET_DRAWER_BODY_HEIGHT, payload: 42 },
]);
});
});
......@@ -23,10 +23,37 @@ describe('whats new mutations', () => {
});
});
describe('setFeatures', () => {
it('sets features to data', () => {
mutations[types.SET_FEATURES](state, 'bells and whistles');
expect(state.features).toBe('bells and whistles');
describe('addFeatures', () => {
it('adds features from data', () => {
mutations[types.ADD_FEATURES](state, ['bells and whistles']);
expect(state.features).toEqual(['bells and whistles']);
});
it('when there are already items, it adds items', () => {
state.features = ['shiny things'];
mutations[types.ADD_FEATURES](state, ['bells and whistles']);
expect(state.features).toEqual(['shiny things', 'bells and whistles']);
});
});
describe('setPageInfo', () => {
it('sets page info', () => {
mutations[types.SET_PAGE_INFO](state, { nextPage: 8 });
expect(state.pageInfo).toEqual({ nextPage: 8 });
});
});
describe('setFetching', () => {
it('sets fetching', () => {
mutations[types.SET_FETCHING](state, true);
expect(state.fetching).toBe(true);
});
});
describe('setDrawerBodyHeight', () => {
it('sets drawerBodyHeight', () => {
mutations[types.SET_DRAWER_BODY_HEIGHT](state, 840);
expect(state.drawerBodyHeight).toBe(840);
});
});
});
import { mount } from '@vue/test-utils';
import { GlDrawer } from '@gitlab/ui';
import { getDrawerBodyHeight } from '~/whats_new/utils/get_drawer_body_height';
describe('~/whats_new/utils/get_drawer_body_height', () => {
let drawerWrapper;
beforeEach(() => {
drawerWrapper = mount(GlDrawer, {
propsData: { open: true },
});
});
afterEach(() => {
drawerWrapper.destroy();
});
const setClientHeight = (el, height) => {
Object.defineProperty(el, 'clientHeight', {
get() {
return height;
},
});
};
const setDrawerDimensions = ({ height, top, headerHeight }) => {
const drawer = drawerWrapper.element;
setClientHeight(drawer, height);
jest.spyOn(drawer, 'getBoundingClientRect').mockReturnValue({ top });
setClientHeight(drawer.querySelector('.gl-drawer-header'), headerHeight);
};
it('calculates height of drawer body', () => {
setDrawerDimensions({ height: 100, top: 5, headerHeight: 40 });
expect(getDrawerBodyHeight(drawerWrapper.element)).toBe(55);
});
});
......@@ -3,21 +3,23 @@
require 'spec_helper'
RSpec.describe WhatsNewHelper do
let(:fixture_dir_glob) { Dir.glob(File.join('spec', 'fixtures', 'whats_new', '*.yml')) }
describe '#whats_new_storage_key' do
subject { helper.whats_new_storage_key }
context 'when version exist' do
before do
allow(helper).to receive(:whats_new_most_recent_version).and_return(version)
allow(Dir).to receive(:glob).with(Rails.root.join('data', 'whats_new', '*.yml')).and_return(fixture_dir_glob)
end
context 'when version exist' do
let(:version) { '84.0' }
it { is_expected.to eq('display-whats-new-notification-84.0') }
it { is_expected.to eq('display-whats-new-notification-01.05') }
end
context 'when recent release items do NOT exist' do
let(:version) { nil }
before do
allow(helper).to receive(:whats_new_release_items).and_return(nil)
end
it { is_expected.to be_nil }
end
......@@ -27,8 +29,6 @@ RSpec.describe WhatsNewHelper do
subject { helper.whats_new_most_recent_release_items_count }
context 'when recent release items exist' do
let(:fixture_dir_glob) { Dir.glob(File.join('spec', 'fixtures', 'whats_new', '*.yml')) }
it 'returns the count from the most recent file' do
expect(Dir).to receive(:glob).with(Rails.root.join('data', 'whats_new', '*.yml')).and_return(fixture_dir_glob)
......@@ -48,4 +48,13 @@ RSpec.describe WhatsNewHelper do
end
end
end
# Testing this important private method here because the request spec required multiple confusing mocks and felt wrong and overcomplicated
describe '#whats_new_items_cache_key' do
it 'returns a key containing the most recent file name and page parameter' do
allow(Dir).to receive(:glob).with(Rails.root.join('data', 'whats_new', '*.yml')).and_return(fixture_dir_glob)
expect(helper.send(:whats_new_items_cache_key, 2)).to eq('whats_new:release_items:file-20201225_01_05:page-2')
end
end
end
......@@ -4,19 +4,44 @@ require 'spec_helper'
RSpec.describe WhatsNewController do
describe 'whats_new_path' do
before do
allow_any_instance_of(WhatsNewController).to receive(:whats_new_most_recent_release_items).and_return('items')
end
context 'with whats_new_drawer feature enabled' do
let(:fixture_dir_glob) { Dir.glob(File.join('spec', 'fixtures', 'whats_new', '*.yml')) }
before do
stub_feature_flags(whats_new_drawer: true)
allow(Dir).to receive(:glob).with(Rails.root.join('data', 'whats_new', '*.yml')).and_return(fixture_dir_glob)
end
it 'is successful' do
context 'with no page param' do
it 'responds with paginated data and headers' do
get whats_new_path, xhr: true
expect(response).to have_gitlab_http_status(:ok)
expect(response.body).to eq([{ title: "bright and sunshinin' day", release: "01.05" }].to_json)
expect(response.headers['X-Next-Page']).to eq(2)
end
end
context 'with page param' do
it 'responds with paginated data and headers' do
get whats_new_path(page: 2), xhr: true
expect(response.body).to eq([{ title: 'bright' }].to_json)
expect(response.headers['X-Next-Page']).to eq(3)
end
it 'returns a 404 if page param is negative' do
get whats_new_path(page: -1), xhr: true
expect(response).to have_gitlab_http_status(:not_found)
end
context 'when there are no more paginated results' do
it 'responds with nil X-Next-Page header' do
get whats_new_path(page: 3), xhr: true
expect(response.body).to eq([{ title: "It's gonna be a bright" }].to_json)
expect(response.headers['X-Next-Page']).to be nil
end
end
end
end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment