diff options
19 files changed, 867 insertions, 107 deletions
diff --git a/content/assets/javascripts/docs.js b/content/assets/javascripts/docs.js index 018c0bb2..e19a305f 100644 --- a/content/assets/javascripts/docs.js +++ b/content/assets/javascripts/docs.js @@ -1,5 +1,5 @@ --- -version: 1 +version: 2 --- var NAV_INLINE_BREAKPOINT = 1100; @@ -36,7 +36,6 @@ function toggleNavigation() { // move document nav to sidebar (function() { var timeofday = document.getElementById('timeofday'); - var tocList = document.querySelector('.js-article-content > ul#markdown-toc'); var main = document.querySelector('.js-main-wrapper'); // Set timeofday var depending on the time // @@ -58,93 +57,6 @@ function toggleNavigation() { } } - // if the document has a top level nav - if (tocList) { - // append to the sidebar - var sidebar = document.getElementById('doc-nav'); - - if (sidebar) { - // if there are items - if (tocList.children.length >= 1) { - var menu = tocList; - $(tocList).addClass('nav nav-pills flex-column'); - $(tocList).find('ul').addClass('nav nav-pills flex-column'); - $(tocList).find('a').addClass('nav-link'); - - // grab the h1's li anchor text - var title = document.createElement('h4'); - title.innerHTML = 'On this page:'; - - // add the text as a title - menu.insertBefore(title, menu.children[0]); - - var hasHelpSection = document.getElementById('help-and-feedback'); - - // Adds help section anchor to the ToC sidebar - if(hasHelpSection) { - var listItem = document.createElement('li'); - var anchor = document.createElement('a'); - var separator = document.createElement('hr'); - - anchor.className = 'nav-link'; - anchor.innerHTML = 'Help and feedback'; - anchor.setAttribute('href', '#help-and-feedback'); - listItem.appendChild(anchor); - - menu.insertBefore(separator, menu.children[menu.children.length]); - menu.insertBefore(listItem, menu.children[menu.children.length]); - } - - sidebar.appendChild(menu); - - var sidebarContent = sidebar.querySelector('ul'); - var sidebarContentHeight = 0; - - // remove whitespace between elements to prevent list spacing issues - sidebarContent.innerHTML = sidebarContent.innerHTML.replace( - new RegExp('>[s\r\n]+<', 'g'), - '><' - ); - - // When we scroll down to the bottom, we don't want the footer covering - // the TOC list (sticky behavior) - document.addEventListener( - 'scroll', - function() { - // Wait a cycle for the dimensions to kick in - if (!sidebarContentHeight) { - sidebarContentHeight = - sidebarContent.getBoundingClientRect().height + 55; - } - - var isTouchingBottom = false; - if (window.innerWidth >= NAV_INLINE_BREAKPOINT) { - isTouchingBottom = - window.scrollY + sidebarContentHeight >= main.offsetHeight; - } - - if (isTouchingBottom) { - sidebarContent.style.top = - main.offsetHeight - - (window.scrollY + sidebarContentHeight) + - 'px'; - } else { - sidebarContent.style.top = ''; - } - }, - { passive: true } - ); - } - } - - // main content has-toc - if (main && main.classList) { - main.classList.add('has-toc'); - } else { - main.className += ' has-toc'; - } - } - document.addEventListener('DOMContentLoaded', function() { var globalNav = document.getElementById('global-nav'); var media = window.matchMedia('(max-width: 1099px)'); @@ -169,14 +81,6 @@ function toggleNavigation() { } }); - if (media.matches) { - var el = document.getElementById('markdown-toc'); - el.classList.add('collapse'); - el.classList.add('out'); - el.style.height = '34px'; - el.previousElementSibling.classList.add('collapsed'); - } - // Adds the ability to auto-scroll to the active item in the TOC $(window).on('activate.bs.scrollspy', function() { const isMobile = window.matchMedia('(max-width: 1099px)').matches; diff --git a/content/assets/stylesheets/toc.scss b/content/assets/stylesheets/toc.scss index 5480bf2f..6b6fb00b 100644 --- a/content/assets/stylesheets/toc.scss +++ b/content/assets/stylesheets/toc.scss @@ -1,5 +1,5 @@ --- -version: 8 +version: 9 --- @import "variables"; @@ -15,7 +15,7 @@ version: 8 font-size: 16px; } - > ul { + .table-of-contents { position: relative; padding: 16px 8px; margin-top: 60px; @@ -32,7 +32,6 @@ version: 8 font-size: 14px; color: $toc-link-color; display: inline; - padding: 3px 7px; &:hover { color: $link-color-nav-hover; @@ -112,11 +111,21 @@ version: 8 } // ToC toggle button - > ul { + .table-of-contents { margin: 0; } } + .sm-collapsing { + height: 0; + overflow: hidden; + transition: height .2s ease; + } + + .sm-collapsed { + display: none; + } + .main.class { float: none; width: inherit; diff --git a/content/frontend/default/components/collapsible_container.vue b/content/frontend/default/components/collapsible_container.vue new file mode 100644 index 00000000..59653ab8 --- /dev/null +++ b/content/frontend/default/components/collapsible_container.vue @@ -0,0 +1,86 @@ +<script> +import { getOuterHeight } from '../../shared/dom'; + +export default { + name: 'CollapsibleContainer', + model: { + prop: 'isCollapsed', + event: 'change', + }, + props: { + isCollapsed: { + type: Boolean, + required: true, + }, + collapsingClass: { + type: String, + required: false, + default: 'sm-collapsing', + }, + collapsedClass: { + type: String, + required: false, + default: 'sm-collapsed', + }, + }, + data() { + return { + isCollapsing: false, + collapsingHeight: 0, + }; + }, + computed: { + styles() { + if (this.isCollapsing) { + return { + height: `${this.collapsingHeight}px`, + }; + } + + return {}; + }, + classes() { + if (this.isCollapsing) { + return this.collapsingClass; + } + if (this.isCollapsed) { + return this.collapsedClass; + } + + return ''; + }, + }, + methods: { + collapse(shouldCollapse) { + if (this.isCollapsing) { + return; + } + // Right away let's flag that we're collapsing so we don't accept anymore updates + this.isCollapsing = true; + + // Let's let our parent go ahead and treat us as collapsed. + this.$emit('change', shouldCollapse); + + // Get start/stop height based on if we're collapsing or expanding + const containerHeight = getOuterHeight(this.$el); + const startHeight = shouldCollapse ? containerHeight : 0; + const stopHeight = shouldCollapse ? 0 : containerHeight; + + // Kick off transition + this.collapsingHeight = startHeight; + setTimeout(() => { + this.collapsingHeight = stopHeight; + }, 50); + + setTimeout(() => { + this.isCollapsing = false; + }, 400); + }, + }, +}; +</script> +<template> + <div :class="classes" :style="styles"> + <slot></slot> + </div> +</template> diff --git a/content/frontend/default/components/table_of_contents.vue b/content/frontend/default/components/table_of_contents.vue new file mode 100644 index 00000000..90e798fb --- /dev/null +++ b/content/frontend/default/components/table_of_contents.vue @@ -0,0 +1,80 @@ +<script> +import TableOfContentsList from './table_of_contents_list.vue'; +import CollapsibleContainer from './collapsible_container.vue'; + +export default { + name: 'TableOfContents', + components: { + CollapsibleContainer, + TableOfContentsList, + }, + props: { + items: { + type: Array, + required: true, + }, + helpAndFeedbackId: { + type: String, + required: false, + default: '', + }, + hasHelpAndFeedback: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + isCollapsed: true, + }; + }, + computed: { + helpAndFeedbackItems() { + return [ + { + text: 'Help and feedback', + href: `#${this.helpAndFeedbackId}`, + id: null, + items: [], + }, + ]; + }, + }, + methods: { + toggleCollapse() { + this.$refs.container.collapse(!this.isCollapsed); + }, + }, +}; +</script> +<template> + <div id="markdown-toc" class="table-of-contents-container position-relative"> + <a + class="toc-collapse" + :class="{ collapsed: isCollapsed }" + role="button" + href="#" + :aria-expanded="!isCollapsed" + aria-controls="markdown-toc" + data-testid="collapse" + @click.prevent="toggleCollapse" + ></a> + <collapsible-container + ref="container" + v-model="isCollapsed" + class="table-of-contents" + data-testid="container" + > + <h4>On this page:</h4> + <table-of-contents-list :items="items" class="my-0" data-testid="main-list" /> + <div v-if="hasHelpAndFeedback" class="border-top mt-3 pt-3"> + <table-of-contents-list + :items="helpAndFeedbackItems" + class="my-0" + data-testid="help-and-feedback" + /> + </div> + </collapsible-container> + </div> +</template> diff --git a/content/frontend/default/components/table_of_contents_list.vue b/content/frontend/default/components/table_of_contents_list.vue new file mode 100644 index 00000000..98fb8599 --- /dev/null +++ b/content/frontend/default/components/table_of_contents_list.vue @@ -0,0 +1,19 @@ +<script> +export default { + name: 'TableOfContentsList', + props: { + items: { + type: Array, + required: true, + }, + }, +}; +</script> +<template> + <ul class="nav nav-pills flex-column"> + <li v-for="(item, index) in items" :key="`${item.text}_${index}`"> + <a :id="item.id" class="nav-link" :href="item.href">{{ item.text }}</a> + <table-of-contents-list v-if="item.items && item.items.length" :items="item.items" /> + </li> + </ul> +</template> diff --git a/content/frontend/default/default.js b/content/frontend/default/default.js index 447bf7f1..a9e9ea52 100644 --- a/content/frontend/default/default.js +++ b/content/frontend/default/default.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import setupTableOfContents from './setup_table_of_contents'; import NavigationToggle from './components/navigation_toggle.vue'; import VersionBanner from './components/version_banner.vue'; @@ -40,4 +41,6 @@ document.addEventListener('DOMContentLoaded', () => { }); }, }); + + setupTableOfContents(); }); diff --git a/content/frontend/default/directives/stick_to_footer.js b/content/frontend/default/directives/stick_to_footer.js new file mode 100644 index 00000000..add7201e --- /dev/null +++ b/content/frontend/default/directives/stick_to_footer.js @@ -0,0 +1,43 @@ +const NAV_INLINE_BREAKPOINT = 1100; +const NAV_TOP_MARGIN = 55; + +const isTouchingBottom = (height, offsetHeight) => { + if (window.innerWidth < NAV_INLINE_BREAKPOINT) { + return false; + } + + return offsetHeight <= window.scrollY + height; +}; + +const getTopOffset = (height, offsetHeight) => { + if (isTouchingBottom(height, offsetHeight)) { + return offsetHeight - (window.scrollY + height); + } + + return 0; +}; + +export default { + bind(el, { value }) { + let contentHeight; + const mainEl = document.querySelector(value); + + el.$_stickToFooter_listener = () => { + if (!contentHeight) { + contentHeight = el.getBoundingClientRect().height + NAV_TOP_MARGIN; + } + const { offsetHeight } = mainEl; + const topOffset = getTopOffset(contentHeight, offsetHeight); + + el.style.top = topOffset < 0 ? `${topOffset}px` : ''; + }; + + // When we scroll down to the bottom, we don't want the footer covering + // the TOC list (sticky behavior) + document.addEventListener('scroll', el.$_stickToFooter_listener, { passive: true }); + }, + unbind(el) { + el.style.top = ''; + document.removeEventListener('scroll', el.$_stickToFooter_listener); + }, +}; diff --git a/content/frontend/default/setup_table_of_contents.js b/content/frontend/default/setup_table_of_contents.js new file mode 100644 index 00000000..8fb5dc69 --- /dev/null +++ b/content/frontend/default/setup_table_of_contents.js @@ -0,0 +1,53 @@ +import Vue from 'vue'; +import TableOfContents from './components/table_of_contents.vue'; +import StickToFooter from './directives/stick_to_footer'; +import { parseTOC } from '../shared/dom_parse_toc'; + +const SIDEBAR_ID = 'doc-nav'; +const MARKDOWN_TOC_ID = 'markdown-toc'; +const HELP_AND_FEEDBACK_ID = 'help-and-feedback'; +const MAIN_SELECTOR = '.js-main-wrapper'; + +export default () => { + const sidebar = document.getElementById(SIDEBAR_ID); + const menu = document.getElementById(MARKDOWN_TOC_ID); + const main = document.querySelector(MAIN_SELECTOR); + const hasHelpAndFeedback = Boolean(document.getElementById(HELP_AND_FEEDBACK_ID)); + + if (!sidebar || !menu) { + return null; + } + + if (main) { + main.classList.add('has-toc'); + } + + const items = parseTOC(menu); + menu.remove(); + + const el = document.createElement('div'); + sidebar.appendChild(el); + + return new Vue({ + el, + directives: { + StickToFooter, + }, + render(h) { + return h(TableOfContents, { + props: { + items, + helpAndFeedbackId: HELP_AND_FEEDBACK_ID, + hasHelpAndFeedback, + }, + directives: [ + { + name: 'stick-to-footer', + value: MAIN_SELECTOR, + expression: MAIN_SELECTOR, + }, + ], + }); + }, + }); +}; diff --git a/content/frontend/shared/dom.js b/content/frontend/shared/dom.js new file mode 100644 index 00000000..d041ac09 --- /dev/null +++ b/content/frontend/shared/dom.js @@ -0,0 +1,22 @@ +/* global $ */ + +/** + * Returns outerHeight of element **even if it's hidden** + * + * NOTE: Uses jQuery because there is no trivial way to do this in + * vaniall JS, and it's nice that jQuery has a reliable out-of-the-box + * solution. + * + * @param {Element} el + */ +export const getOuterHeight = el => $(el).outerHeight(); + +/** + * Find the first child of the given element with the given tag name + * + * @param {Element} el + * @param {String} tagName + * @returns {Element | null} Returns first child that matches the given tagName (or null if not found) + */ +export const findChildByTagName = (el, tagName) => + Array.from(el.childNodes).find(x => x.tagName === tagName.toUpperCase()); diff --git a/content/frontend/shared/dom_parse_toc.js b/content/frontend/shared/dom_parse_toc.js new file mode 100644 index 00000000..552550a1 --- /dev/null +++ b/content/frontend/shared/dom_parse_toc.js @@ -0,0 +1,49 @@ +/* eslint-disable import/prefer-default-export */ +import { findChildByTagName } from './dom'; + +const TAG_LI = 'LI'; +const TAG_A = 'A'; +const TAG_UL = 'UL'; + +/** + * Parses the given HTML Table of Contents into a data structure + * + * ``` + * type Item = { text: String, href: String, id: String, items: Item[] } + * + * parseTOC: Element => Item[] + * ``` + * + * @param {Element} menu Parent <ul> element + */ +export const parseTOC = menu => { + const items = []; + + if (!menu) { + return items; + } + + menu.childNodes.forEach(li => { + if (li.tagName !== TAG_LI) { + return; + } + + const link = findChildByTagName(li, TAG_A); + const subMenu = findChildByTagName(li, TAG_UL); + + if (!link) { + return; + } + + const item = { + text: link.textContent, + href: link.getAttribute('href'), + id: link.id, + items: parseTOC(subMenu), + }; + + items.push(item); + }); + + return items; +}; diff --git a/layouts/default.html b/layouts/default.html index b4633c45..20c90920 100644 --- a/layouts/default.html +++ b/layouts/default.html @@ -28,13 +28,9 @@ <% end %> <li class="breadcrumb"><%= @item.key?(:title) ? "#{@item[:title]}" : "Current page" %></li> </ul> - <div id="doc-nav" class="doc-nav"> - <a class="toc-collapse" data-toggle="collapse" href="#markdown-toc" aria-expanded="true" aria-controls="markdown-toc"></a> - </div> + <div id="doc-nav" class="doc-nav"></div> <% else %> - <div id="doc-nav" class="doc-nav toc-no-breadcrumbs"> - <a class="toc-collapse" role="button" data-toggle="collapse" href="#markdown-toc" aria-expanded="true" aria-controls="markdown-toc"></a> - </div> + <div id="doc-nav" class="doc-nav toc-no-breadcrumbs"></div> <% end %> <% end %> <% if @item[:title] %> 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')]), + ]), +]; |