Commit 485f97d7 authored by Tim Zallmann's avatar Tim Zallmann

Merge branch 'andr3-startup_css_helper-trigger-anytime' into 'master'

Simplify StartupCSS JS Helper and fix autosize problems

See merge request gitlab-org/gitlab!42462
parents 3c81964d 04c4f003
import Autosize from 'autosize'; import Autosize from 'autosize';
import { waitForCSSLoaded } from '../helpers/startup_css_helper';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const autosizeEls = document.querySelectorAll('.js-autosize'); waitForCSSLoaded(() => {
const autosizeEls = document.querySelectorAll('.js-autosize');
Autosize(autosizeEls); Autosize(autosizeEls);
Autosize.update(autosizeEls); Autosize.update(autosizeEls);
autosizeEls.forEach(el => el.classList.add('js-autosize-initialized'));
});
}); });
const CSS_LOADED_EVENT = 'CSSLoaded';
const STARTUP_LINK_LOADED_EVENT = 'CSSStartupLinkLoaded';
const getAllStartupLinks = (() => {
let links = null;
return () => {
if (!links) {
links = Array.from(document.querySelectorAll('link[data-startupcss]'));
}
return links;
};
})();
const isStartupLinkLoaded = ({ dataset }) => dataset.startupcss === 'loaded';
const allLinksLoaded = () => getAllStartupLinks().every(isStartupLinkLoaded);
const handleStartupEvents = () => {
if (allLinksLoaded()) {
document.dispatchEvent(new CustomEvent(CSS_LOADED_EVENT));
document.removeEventListener(STARTUP_LINK_LOADED_EVENT, handleStartupEvents);
}
};
/* Wait for.... The methods can be used: /* Wait for.... The methods can be used:
- with a callback (preferred), - with a callback (preferred),
waitFor(action) waitFor(action)
...@@ -8,61 +30,17 @@ ...@@ -8,61 +30,17 @@
- with await, - with await,
await waitFor; await waitFor;
action(); action();
*/ -*/
export const waitForCSSLoaded = (action = () => {}) => {
const CSS_LOADED_EVENT = 'CSSLoaded'; if (!gon.features.startupCss || allLinksLoaded()) {
const DOM_LOADED_EVENT = 'DOMContentLoaded'; return new Promise(resolve => {
const STARTUP_LINK_LOADED_EVENT = 'CSSStartupLinkLoaded';
const isStartupLinkLoaded = ({ dataset }) => dataset.startupcss === 'loaded';
export const handleLoadedEvents = (action = () => {}) => {
let isCssLoaded = false;
let eventsList = [CSS_LOADED_EVENT, DOM_LOADED_EVENT];
return ({ type } = {}) => {
eventsList = eventsList.filter(e => e !== type);
if (isCssLoaded) {
return;
}
if (!eventsList.length) {
isCssLoaded = true;
action(); action();
} resolve();
}; });
};
export const handleStartupEvents = (action = () => {}) => {
if (!gon.features.startupCss) {
return action;
} }
const startupLinks = Array.from(document.querySelectorAll('link[data-startupcss]'));
return () => {
if (startupLinks.every(isStartupLinkLoaded)) {
action();
}
};
};
export const waitForStartupLinks = () => { return new Promise(resolve => {
let eventListener; document.addEventListener(CSS_LOADED_EVENT, resolve, { once: true });
const promise = new Promise(resolve => { document.addEventListener(STARTUP_LINK_LOADED_EVENT, handleStartupEvents);
eventListener = handleStartupEvents(resolve);
document.addEventListener(STARTUP_LINK_LOADED_EVENT, eventListener);
}).then(() => {
document.dispatchEvent(new CustomEvent(CSS_LOADED_EVENT));
document.removeEventListener(STARTUP_LINK_LOADED_EVENT, eventListener);
});
document.dispatchEvent(new CustomEvent(STARTUP_LINK_LOADED_EVENT));
return promise;
};
export const waitForCSSLoaded = (action = () => {}) => {
let eventListener;
const promise = new Promise(resolve => {
eventListener = handleLoadedEvents(resolve);
document.addEventListener(DOM_LOADED_EVENT, eventListener, { once: true });
document.addEventListener(CSS_LOADED_EVENT, eventListener, { once: true });
}).then(action); }).then(action);
waitForStartupLinks();
return promise;
}; };
...@@ -5,149 +5,151 @@ import { __ } from '~/locale'; ...@@ -5,149 +5,151 @@ import { __ } from '~/locale';
import CodeCoverage from '../components/code_coverage.vue'; import CodeCoverage from '../components/code_coverage.vue';
import SeriesDataMixin from './series_data_mixin'; import SeriesDataMixin from './series_data_mixin';
waitForCSSLoaded(() => { document.addEventListener('DOMContentLoaded', () => {
const languagesContainer = document.getElementById('js-languages-chart'); waitForCSSLoaded(() => {
const codeCoverageContainer = document.getElementById('js-code-coverage-chart'); const languagesContainer = document.getElementById('js-languages-chart');
const monthContainer = document.getElementById('js-month-chart'); const codeCoverageContainer = document.getElementById('js-code-coverage-chart');
const weekdayContainer = document.getElementById('js-weekday-chart'); const monthContainer = document.getElementById('js-month-chart');
const hourContainer = document.getElementById('js-hour-chart'); const weekdayContainer = document.getElementById('js-weekday-chart');
const LANGUAGE_CHART_HEIGHT = 300; const hourContainer = document.getElementById('js-hour-chart');
const reorderWeekDays = (weekDays, firstDayOfWeek = 0) => { const LANGUAGE_CHART_HEIGHT = 300;
if (firstDayOfWeek === 0) { const reorderWeekDays = (weekDays, firstDayOfWeek = 0) => {
return weekDays; if (firstDayOfWeek === 0) {
} return weekDays;
}
return Object.keys(weekDays).reduce((acc, dayName, idx, arr) => { return Object.keys(weekDays).reduce((acc, dayName, idx, arr) => {
const reorderedDayName = arr[(idx + firstDayOfWeek) % arr.length]; const reorderedDayName = arr[(idx + firstDayOfWeek) % arr.length];
return { return {
...acc, ...acc,
[reorderedDayName]: weekDays[reorderedDayName], [reorderedDayName]: weekDays[reorderedDayName],
}; };
}, {}); }, {});
}; };
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new Vue({ new Vue({
el: languagesContainer, el: languagesContainer,
components: { components: {
GlColumnChart, GlColumnChart,
},
data() {
return {
chartData: JSON.parse(languagesContainer.dataset.chartData),
};
},
computed: {
seriesData() {
return { full: this.chartData.map(d => [d.label, d.value]) };
}, },
}, data() {
render(h) { return {
return h(GlColumnChart, { chartData: JSON.parse(languagesContainer.dataset.chartData),
props: { };
data: this.seriesData, },
xAxisTitle: __('Used programming language'), computed: {
yAxisTitle: __('Percentage'), seriesData() {
xAxisType: 'category', return { full: this.chartData.map(d => [d.label, d.value]) };
},
attrs: {
height: LANGUAGE_CHART_HEIGHT,
}, },
}); },
}, render(h) {
}); return h(GlColumnChart, {
props: {
data: this.seriesData,
xAxisTitle: __('Used programming language'),
yAxisTitle: __('Percentage'),
xAxisType: 'category',
},
attrs: {
height: LANGUAGE_CHART_HEIGHT,
},
});
},
});
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new Vue({ new Vue({
el: codeCoverageContainer, el: codeCoverageContainer,
render(h) { render(h) {
return h(CodeCoverage, { return h(CodeCoverage, {
props: { props: {
graphEndpoint: codeCoverageContainer.dataset?.graphEndpoint, graphEndpoint: codeCoverageContainer.dataset?.graphEndpoint,
}, },
}); });
}, },
}); });
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new Vue({ new Vue({
el: monthContainer, el: monthContainer,
components: { components: {
GlColumnChart, GlColumnChart,
}, },
mixins: [SeriesDataMixin], mixins: [SeriesDataMixin],
data() { data() {
return { return {
chartData: JSON.parse(monthContainer.dataset.chartData), chartData: JSON.parse(monthContainer.dataset.chartData),
}; };
}, },
render(h) { render(h) {
return h(GlColumnChart, { return h(GlColumnChart, {
props: { props: {
data: this.seriesData, data: this.seriesData,
xAxisTitle: __('Day of month'), xAxisTitle: __('Day of month'),
yAxisTitle: __('No. of commits'), yAxisTitle: __('No. of commits'),
xAxisType: 'category', xAxisType: 'category',
}, },
}); });
}, },
}); });
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new Vue({ new Vue({
el: weekdayContainer, el: weekdayContainer,
components: { components: {
GlColumnChart, GlColumnChart,
}, },
data() { data() {
return { return {
chartData: JSON.parse(weekdayContainer.dataset.chartData), chartData: JSON.parse(weekdayContainer.dataset.chartData),
}; };
},
computed: {
seriesData() {
const weekDays = reorderWeekDays(this.chartData, gon.first_day_of_week);
const data = Object.keys(weekDays).reduce((acc, key) => {
acc.push([key, weekDays[key]]);
return acc;
}, []);
return { full: data };
}, },
}, computed: {
render(h) { seriesData() {
return h(GlColumnChart, { const weekDays = reorderWeekDays(this.chartData, gon.first_day_of_week);
props: { const data = Object.keys(weekDays).reduce((acc, key) => {
data: this.seriesData, acc.push([key, weekDays[key]]);
xAxisTitle: __('Weekday'), return acc;
yAxisTitle: __('No. of commits'), }, []);
xAxisType: 'category', return { full: data };
}, },
}); },
}, render(h) {
}); return h(GlColumnChart, {
props: {
data: this.seriesData,
xAxisTitle: __('Weekday'),
yAxisTitle: __('No. of commits'),
xAxisType: 'category',
},
});
},
});
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new Vue({ new Vue({
el: hourContainer, el: hourContainer,
components: { components: {
GlColumnChart, GlColumnChart,
}, },
mixins: [SeriesDataMixin], mixins: [SeriesDataMixin],
data() { data() {
return { return {
chartData: JSON.parse(hourContainer.dataset.chartData), chartData: JSON.parse(hourContainer.dataset.chartData),
}; };
}, },
render(h) { render(h) {
return h(GlColumnChart, { return h(GlColumnChart, {
props: { props: {
data: this.seriesData, data: this.seriesData,
xAxisTitle: __('Hour (UTC)'), xAxisTitle: __('Hour (UTC)'),
yAxisTitle: __('No. of commits'), yAxisTitle: __('No. of commits'),
xAxisType: 'category', xAxisType: 'category',
}, },
}); });
}, },
});
}); });
}); });
---
title: Simplify StartupCSS JS Helper and fix autosize issues under StartupCSS
merge_request: 42462
author:
type: fixed
import $ from 'jquery';
import '~/behaviors/autosize'; import '~/behaviors/autosize';
function load() { function load() {
$(document).trigger('load'); document.dispatchEvent(new Event('DOMContentLoaded'));
} }
jest.mock('~/helpers/startup_css_helper', () => {
return {
waitForCSSLoaded: jest.fn().mockImplementation(cb => cb.apply()),
};
});
describe('Autosize behavior', () => { describe('Autosize behavior', () => {
beforeEach(() => { beforeEach(() => {
setFixtures('<textarea class="js-autosize" style="resize: vertical"></textarea>'); setFixtures('<textarea class="js-autosize"></textarea>');
}); });
it('does not overwrite the resize property', () => { it('is applied to the textarea', () => {
load(); load();
expect($('textarea')).toHaveCss({ const textarea = document.querySelector('textarea');
resize: 'vertical', expect(textarea.classList).toContain('js-autosize-initialized');
});
}); });
}); });
import { import { waitForCSSLoaded } from '../../../app/assets/javascripts/helpers/startup_css_helper';
handleLoadedEvents,
waitForCSSLoaded,
} from '../../../app/assets/javascripts/helpers/startup_css_helper';
describe('handleLoadedEvents', () => {
let mock;
beforeEach(() => {
mock = jest.fn();
});
it('should not call the callback on wrong conditions', () => {
const resolverToCall = handleLoadedEvents(mock);
resolverToCall({ type: 'UnrelatedEvent' });
resolverToCall({ type: 'UnrelatedEvent' });
resolverToCall({ type: 'UnrelatedEvent' });
resolverToCall({ type: 'UnrelatedEvent' });
resolverToCall({ type: 'CSSLoaded' });
resolverToCall();
expect(mock).not.toHaveBeenCalled();
});
it('should call the callback when all the events have been triggered', () => {
const resolverToCall = handleLoadedEvents(mock);
resolverToCall();
resolverToCall({ type: 'DOMContentLoaded' });
resolverToCall({ type: 'CSSLoaded' });
resolverToCall();
expect(mock).toHaveBeenCalledTimes(1);
});
});
describe('waitForCSSLoaded', () => { describe('waitForCSSLoaded', () => {
let mock; let mockedCallback;
beforeEach(() => { beforeEach(() => {
mock = jest.fn(); mockedCallback = jest.fn();
}); });
describe('with startup css disabled', () => { describe('Promise-like api', () => {
beforeEach(() => { it('can be used with a callback', async () => {
gon.features = { await waitForCSSLoaded(mockedCallback);
startupCss: false, expect(mockedCallback).toHaveBeenCalledTimes(1);
};
}); });
it('should call CssLoaded when the conditions are met', async () => { it('can be used as a promise', async () => {
const docAddListener = jest.spyOn(document, 'addEventListener'); await waitForCSSLoaded().then(mockedCallback);
const docRemoveListener = jest.spyOn(document, 'removeEventListener'); expect(mockedCallback).toHaveBeenCalledTimes(1);
const docDispatch = jest.spyOn(document, 'dispatchEvent'); });
const events = waitForCSSLoaded(mock); });
expect(docAddListener).toHaveBeenCalledTimes(3); describe('with startup css disabled', () => {
expect(docDispatch.mock.calls[0][0].type).toBe('CSSStartupLinkLoaded'); gon.features = {
startupCss: false,
};
document.dispatchEvent(new CustomEvent('DOMContentLoaded')); it('should invoke the action right away', async () => {
const events = waitForCSSLoaded(mockedCallback);
await events; await events;
expect(docDispatch).toHaveBeenCalledTimes(3); expect(mockedCallback).toHaveBeenCalledTimes(1);
expect(docDispatch.mock.calls[2][0].type).toBe('CSSLoaded');
expect(docRemoveListener).toHaveBeenCalledTimes(1);
expect(mock).toHaveBeenCalledTimes(1);
}); });
}); });
describe('with startup css enabled', () => { describe('with startup css enabled', () => {
let docAddListener; gon.features = {
let docRemoveListener; startupCss: true,
let docDispatch; };
beforeEach(() => {
docAddListener = jest.spyOn(document, 'addEventListener');
docRemoveListener = jest.spyOn(document, 'removeEventListener');
docDispatch = jest.spyOn(document, 'dispatchEvent');
gon.features = {
startupCss: true,
};
});
it('should call CssLoaded if the assets are cached', async () => { it('should dispatch CSSLoaded when the assets are cached or already loaded', async () => {
const events = waitForCSSLoaded(mock); setFixtures(`
const fixtures = `
<link href="one.css" data-startupcss="loaded"> <link href="one.css" data-startupcss="loaded">
<link href="two.css" data-startupcss="loaded"> <link href="two.css" data-startupcss="loaded">
`; `);
setFixtures(fixtures); await waitForCSSLoaded(mockedCallback);
expect(docAddListener).toHaveBeenCalledTimes(3); expect(mockedCallback).toHaveBeenCalledTimes(1);
expect(docDispatch.mock.calls[0][0].type).toBe('CSSStartupLinkLoaded');
document.dispatchEvent(new CustomEvent('DOMContentLoaded'));
await events;
expect(docDispatch).toHaveBeenCalledTimes(3);
expect(docDispatch.mock.calls[2][0].type).toBe('CSSLoaded');
expect(docRemoveListener).toHaveBeenCalledTimes(1);
expect(mock).toHaveBeenCalledTimes(1);
}); });
it('should wait to call CssLoaded until the assets are loaded', async () => { it('should wait to call CssLoaded until the assets are loaded', async () => {
const events = waitForCSSLoaded(mock); setFixtures(`
const fixtures = `
<link href="one.css" data-startupcss="loading"> <link href="one.css" data-startupcss="loading">
<link href="two.css" data-startupcss="loading"> <link href="two.css" data-startupcss="loading">
`; `);
setFixtures(fixtures); const events = waitForCSSLoaded(mockedCallback);
expect(docAddListener).toHaveBeenCalledTimes(3);
expect(docDispatch.mock.calls[0][0].type).toBe('CSSStartupLinkLoaded');
document document
.querySelectorAll('[data-startupcss="loading"]') .querySelectorAll('[data-startupcss="loading"]')
.forEach(elem => elem.setAttribute('data-startupcss', 'loaded')); .forEach(elem => elem.setAttribute('data-startupcss', 'loaded'));
document.dispatchEvent(new CustomEvent('DOMContentLoaded'));
document.dispatchEvent(new CustomEvent('CSSStartupLinkLoaded')); document.dispatchEvent(new CustomEvent('CSSStartupLinkLoaded'));
await events; await events;
expect(docDispatch).toHaveBeenCalledTimes(4); expect(mockedCallback).toHaveBeenCalledTimes(1);
expect(docDispatch.mock.calls[3][0].type).toBe('CSSLoaded');
expect(docRemoveListener).toHaveBeenCalledTimes(1);
expect(mock).toHaveBeenCalledTimes(1);
}); });
}); });
}); });
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