diff options
author | Paul Slaughter <pslaughter@gitlab.com> | 2020-04-29 07:57:08 +0300 |
---|---|---|
committer | Paul Slaughter <pslaughter@gitlab.com> | 2020-05-20 01:06:11 +0300 |
commit | 26bb98496b060ef60d6edc265fa7a12ee9d612a4 (patch) | |
tree | adb7f036b85683099bca6a23d3d76ee06ec1396b /spec | |
parent | a61458419fe97029cc9d1c198bd313dd6af548b6 (diff) |
Create Vue table_of_contents
Also:
- Updates toc.scss to look for .table-of-contents
- Creates some dom helpers in shared
- Stick to footer directive
- Moves toc collapse button to component
- Fixes issue where collapsed on mobile
affects full view
Diffstat (limited to 'spec')
8 files changed, 496 insertions, 0 deletions
diff --git a/spec/frontend/default/components/__snapshots__/table_of_contents_list_spec.js.snap b/spec/frontend/default/components/__snapshots__/table_of_contents_list_spec.js.snap new file mode 100644 index 00000000..6a00c5ea --- /dev/null +++ b/spec/frontend/default/components/__snapshots__/table_of_contents_list_spec.js.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`frontend/default/components/table_of_contents_list with empty items shows empty ul 1`] = ` +<ul + class="nav nav-pills flex-column" +/> +`; diff --git a/spec/frontend/default/components/__snapshots__/table_of_contents_spec.js.snap b/spec/frontend/default/components/__snapshots__/table_of_contents_spec.js.snap new file mode 100644 index 00000000..6483dea6 --- /dev/null +++ b/spec/frontend/default/components/__snapshots__/table_of_contents_spec.js.snap @@ -0,0 +1,137 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`frontend/default/components/table_of_contents matches snapshot 1`] = ` +<div + class="table-of-contents-container position-relative" + id="markdown-toc" +> + <a + aria-controls="markdown-toc" + class="toc-collapse collapsed" + data-testid="collapse" + href="#" + role="button" + /> + + <div + class="table-of-contents sm-collapsed" + data-testid="container" + > + <h4> + On this page: + </h4> + + <ul + class="nav nav-pills flex-column my-0" + data-testid="main-list" + > + <li> + <a + class="nav-link" + href="#lorem" + id="Lorem-anchor" + > + Lorem + </a> + + <ul + class="nav nav-pills flex-column" + > + <li> + <a + class="nav-link" + href="#lorem-" + id="Lorem 2-anchor" + > + Lorem 2 + </a> + + <!----> + </li> + </ul> + </li> + <li> + <a + class="nav-link" + href="#ipsum" + id="Ipsum-anchor" + > + Ipsum + </a> + + <ul + class="nav nav-pills flex-column" + > + <li> + <a + class="nav-link" + href="#dolar" + id="Dolar-anchor" + > + Dolar + </a> + + <ul + class="nav nav-pills flex-column" + > + <li> + <a + class="nav-link" + href="#sit" + id="Sit-anchor" + > + Sit + </a> + + <!----> + </li> + <li> + <a + class="nav-link" + href="#amit" + id="Amit-anchor" + > + Amit + </a> + + <!----> + </li> + <li> + <a + class="nav-link" + href="#test" + id="Test-anchor" + > + Test + </a> + + <!----> + </li> + </ul> + </li> + </ul> + </li> + </ul> + + <div + class="border-top mt-3 pt-3" + > + <ul + class="nav nav-pills flex-column my-0" + data-testid="help-and-feedback" + > + <li> + <a + class="nav-link" + href="#test-help-and-feedback" + > + Help and feedback + </a> + + <!----> + </li> + </ul> + </div> + </div> +</div> +`; diff --git a/spec/frontend/default/components/collapsible_container_spec.js b/spec/frontend/default/components/collapsible_container_spec.js new file mode 100644 index 00000000..259cdf8c --- /dev/null +++ b/spec/frontend/default/components/collapsible_container_spec.js @@ -0,0 +1,124 @@ +import { shallowMount } from '@vue/test-utils'; +import CollapsibleContainer from '../../../../content/frontend/default/components/collapsible_container.vue'; +import * as dom from '../../../../content/frontend/shared/dom'; + +const TEST_COLLAPSING_CLASS = 'test-collapsing'; +const TEST_COLLAPSED_CLASS = 'test-collapsed'; +const TEST_SLOT = 'Lorem ipsum dolar sit amit'; +const TEST_OUTER_HEIGHT = 400; +const KICKOFF_DELAY = 50; +const FINISH_DELAY = 400; + +describe('frontend/default/components/collapsible_container', () => { + let wrapper; + + beforeEach(() => { + // jquery is not available in Jest yet so we need to mock this method + jest.spyOn(dom, 'getOuterHeight').mockImplementation(x => Number(x.dataset.testOuterHeight)); + jest.useFakeTimers(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + + jest.useRealTimers(); + }); + + const createComponent = (props = {}) => { + wrapper = shallowMount(CollapsibleContainer, { + propsData: { + collapsingClass: TEST_COLLAPSING_CLASS, + collapsedClass: TEST_COLLAPSED_CLASS, + ...props, + }, + slots: { + default: TEST_SLOT, + }, + attrs: { + 'data-test-outer-height': TEST_OUTER_HEIGHT.toString(), + }, + }); + }; + const findStyleHeight = () => wrapper.element.style.height; + const waitForKickoff = () => jest.advanceTimersByTime(KICKOFF_DELAY); + const waitForTransition = () => jest.advanceTimersByTime(FINISH_DELAY - KICKOFF_DELAY); + + describe.each` + isCollapsed | startHeight | endHeight | startClasses | endClasses + ${true} | ${0} | ${TEST_OUTER_HEIGHT} | ${[TEST_COLLAPSED_CLASS]} | ${[]} + ${false} | ${TEST_OUTER_HEIGHT} | ${0} | ${[]} | ${[TEST_COLLAPSED_CLASS]} + `( + 'when isCollapsed = $isCollapsed', + ({ isCollapsed, startHeight, endHeight, startClasses, endClasses }) => { + beforeEach(() => { + createComponent({ isCollapsed }); + + return wrapper.vm.$nextTick(); + }); + + it('renders slot', () => { + expect(wrapper.text()).toBe(TEST_SLOT); + }); + + it('has starting classes', () => { + expect(wrapper.classes()).toEqual(startClasses); + }); + + it('has not emitted anything', () => { + expect(wrapper.emitted()).toEqual({}); + }); + + describe('when collapse is triggered', () => { + beforeEach(() => { + wrapper.vm.collapse(!isCollapsed); + + // set props because this is what would naturally happen with `v-model` + wrapper.setProps({ isCollapsed: !isCollapsed }); + }); + + it('has collapsing class', () => { + expect(wrapper.classes()).toEqual([TEST_COLLAPSING_CLASS]); + }); + + it('emits change', () => { + expect(wrapper.emitted().change).toEqual([[!isCollapsed]]); + }); + + it('sets starting height', () => { + expect(findStyleHeight()).toEqual(`${startHeight}px`); + }); + + it('triggering collapse again does not do anything', () => { + wrapper.vm.collapse(isCollapsed); + + expect(wrapper.emitted().change).toEqual([[!isCollapsed]]); + }); + + describe('after animation kickoff delay', () => { + beforeEach(() => { + waitForKickoff(); + }); + + it('sets ending height', () => { + expect(findStyleHeight()).toEqual(`${endHeight}px`); + }); + + describe('after transition', () => { + beforeEach(() => { + waitForTransition(); + }); + + it('does not set height', () => { + expect(findStyleHeight()).toBe(''); + }); + + it('sets ending classes', () => { + expect(wrapper.classes()).toEqual(endClasses); + }); + }); + }); + }); + }, + ); +}); diff --git a/spec/frontend/default/components/table_of_contents_list_spec.js b/spec/frontend/default/components/table_of_contents_list_spec.js new file mode 100644 index 00000000..2d69f880 --- /dev/null +++ b/spec/frontend/default/components/table_of_contents_list_spec.js @@ -0,0 +1,46 @@ +import { mount } from '@vue/test-utils'; +import { parseTOC } from '../../../../content/frontend/shared/dom_parse_toc'; +import TableOfContentsList from '../../../../content/frontend/default/components/table_of_contents_list.vue'; +import { createExampleToc } from '../../shared/toc_helper'; + +describe('frontend/default/components/table_of_contents_list', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = mount(TableOfContentsList, { + propsData: { + ...props, + }, + }); + }; + + const findItemsData = () => parseTOC(wrapper.element); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('with items', () => { + let items; + + beforeEach(() => { + items = createExampleToc(); + createComponent({ items }); + }); + + it('renders all items', () => { + expect(findItemsData()).toEqual(items); + }); + }); + + describe('with empty items', () => { + beforeEach(() => { + createComponent({ items: [] }); + }); + + it('shows empty ul', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); +}); diff --git a/spec/frontend/default/components/table_of_contents_spec.js b/spec/frontend/default/components/table_of_contents_spec.js new file mode 100644 index 00000000..a256cae5 --- /dev/null +++ b/spec/frontend/default/components/table_of_contents_spec.js @@ -0,0 +1,102 @@ +import { shallowMount, mount } from '@vue/test-utils'; +import TableOfContents from '../../../../content/frontend/default/components/table_of_contents.vue'; +import * as dom from '../../../../content/frontend/shared/dom'; +import { createExampleToc } from '../../shared/toc_helper'; + +const TEST_ITEMS = createExampleToc(); +const TEST_HELP_AND_FEEDBACK_ID = 'test-help-and-feedback'; + +describe('frontend/default/components/table_of_contents', () => { + let wrapper; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + beforeEach(() => { + // jquery is not available in Jest yet so we need to mock this method + jest.spyOn(dom, 'getOuterHeight').mockReturnValue(100); + }); + + const createComponent = (props = {}, mountFn = shallowMount) => { + wrapper = mountFn(TableOfContents, { + propsData: { + items: TEST_ITEMS, + helpAndFeedbackId: TEST_HELP_AND_FEEDBACK_ID, + ...props, + }, + }); + }; + + const findCollapseButton = () => wrapper.find('[data-testid="collapse"]'); + const findCollapsibleContainer = () => wrapper.find('[data-testid="container"]'); + const findMainList = () => wrapper.find('[data-testid="main-list"]'); + const findHelpAndFeedback = () => wrapper.find('[data-testid="help-and-feedback"]'); + const clickCollapseButton = () => findCollapseButton().trigger('click'); + + it('matches snapshot', () => { + createComponent({ hasHelpAndFeedback: true }, mount); + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('with hasHelpAndFeedback', () => { + beforeEach(() => { + createComponent({ hasHelpAndFeedback: true }); + }); + + it('shows help and feedback', () => { + expect(findHelpAndFeedback().exists()).toBe(true); + expect(findHelpAndFeedback().props('items')).toEqual([ + { + href: `#${TEST_HELP_AND_FEEDBACK_ID}`, + id: null, + items: [], + text: 'Help and feedback', + }, + ]); + }); + }); + + describe('default', () => { + beforeEach(() => { + createComponent({}, mount); + }); + + it('does not show help and feedback', () => { + expect(findHelpAndFeedback().exists()).toBe(false); + }); + + it('renders toc list', () => { + expect(findMainList().props('items')).toEqual(TEST_ITEMS); + }); + + it('is initially collapsed', () => { + expect(findCollapseButton().classes('collapsed')).toBe(true); + expect(findCollapsibleContainer().classes('sm-collapsed')).toBe(true); + }); + + describe('when collapse button is pressed', () => { + beforeEach(() => { + clickCollapseButton(); + }); + + it('starts expanding', () => { + expect(findCollapsibleContainer().classes('sm-collapsing')).toBe(true); + }); + + it('updates button class', () => { + expect(findCollapseButton().classes('collapsed')).toBe(false); + }); + + it('when button pressed again, nothing happens because in the middle of collapsing', () => { + clickCollapseButton(); + + return wrapper.vm.$nextTick(() => { + expect(findCollapsibleContainer().classes('sm-collapsing')).toBe(true); + expect(findCollapseButton().classes('collapsed')).toBe(false); + }); + }); + }); + }); +}); diff --git a/spec/frontend/shared/dom_parse_toc_spec.js b/spec/frontend/shared/dom_parse_toc_spec.js new file mode 100644 index 00000000..adcacb90 --- /dev/null +++ b/spec/frontend/shared/dom_parse_toc_spec.js @@ -0,0 +1,18 @@ +import { parseTOC } from '../../../content/frontend/shared/dom_parse_toc'; +import { createItem, createTOCElement, createExampleToc } from './toc_helper'; + +describe('frontend/shared/dom_parse_toc', () => { + it('parses nested HTML list', () => { + const list = createExampleToc(); + const el = createTOCElement(list); + + expect(parseTOC(el)).toEqual(list); + }); + + it('skips items that do not have links', () => { + const list = [createItem('Lorem'), { items: [createItem('no link')] }, createItem('Ipsum')]; + const el = createTOCElement(list); + + expect(parseTOC(el)).toEqual([createItem('Lorem'), createItem('Ipsum')]); + }); +}); diff --git a/spec/frontend/shared/dom_spec.js b/spec/frontend/shared/dom_spec.js new file mode 100644 index 00000000..ceb09708 --- /dev/null +++ b/spec/frontend/shared/dom_spec.js @@ -0,0 +1,32 @@ +import { findChildByTagName } from '../../../content/frontend/shared/dom'; + +describe('frontend/shared/dom', () => { + const createElementWithChildren = children => { + const el = document.createElement('div'); + + children.forEach(tag => { + const child = document.createElement(tag); + el.appendChild(child); + }); + + return el; + }; + + describe('findChildByTagName', () => { + it.each` + children | tagName | expectedIndex + ${['div', 'p', 'ul', 'p']} | ${'p'} | ${1} + ${['div', 'ul']} | ${'p'} | ${-1} + ${['li', 'li', 'li']} | ${'li'} | ${0} + ${[]} | ${'li'} | ${-1} + `( + 'with children $children and $tagName, returns $expectedIndex child', + ({ children, tagName, expectedIndex }) => { + const el = createElementWithChildren(children); + const expectedChild = el.childNodes[expectedIndex]; + + expect(findChildByTagName(el, tagName)).toBe(expectedChild); + }, + ); + }); +}); diff --git a/spec/frontend/shared/toc_helper.js b/spec/frontend/shared/toc_helper.js new file mode 100644 index 00000000..574f1d17 --- /dev/null +++ b/spec/frontend/shared/toc_helper.js @@ -0,0 +1,30 @@ +export const createItem = (text, items = []) => ({ + text, + href: `#${text.replace(/[^a-zA-Z-]+/g, '-').toLowerCase()}`, + id: `${text}-anchor`, + items, +}); + +export const buildHTML = list => + list + .map( + ({ text, href, id, items }) => + `<li> +${text ? `<a href="${href}" id="${id}">${text}</a>` : ''} +${items?.length ? `<ul>${buildHTML(items)}</ul>` : ''} +</li>`, + ) + .join(''); + +export const createTOCElement = list => { + const ul = document.createElement('ul'); + ul.innerHTML = buildHTML(list); + return ul; +}; + +export const createExampleToc = () => [ + createItem('Lorem', [createItem('Lorem 2')]), + createItem('Ipsum', [ + createItem('Dolar', [createItem('Sit'), createItem('Amit'), createItem('Test')]), + ]), +]; |