• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

yext / answers-search-ui / 4245875754

pending completion
4245875754

push

github

Oliver Shi
Merge branch 'master' into develop

1962 of 3346 branches covered (58.64%)

Branch coverage included in aggregate %.

10 of 10 new or added lines in 1 file covered. (100.0%)

3374 of 5339 relevant lines covered (63.2%)

26.87 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

54.03
/src/ui/components/navigation/navigationcomponent.js
1
/** @module NavigationComponent */
2

3
/* global Node */
4

5
import Component from '../component';
6
import { AnswersComponentError } from '../../../core/errors/errors';
7
import StorageKeys from '../../../core/storage/storagekeys';
8
import DOM from '../../dom/dom';
9
import { mergeTabOrder, getDefaultTabOrder, getUrlParams } from '../../tools/taborder';
10
import { filterParamsForExperienceLink, replaceUrlParams } from '../../../core/utils/urlutils.js';
11
import TranslationFlagger from '../../i18n/translationflagger';
12
import SearchParams from '../../dom/searchparams';
13

14
/**
15
 * The debounce duration for resize events
16
 * @type {number}
17
 */
18
const RESIZE_DEBOUNCE = 100;
24✔
19

20
/**
21
 * The breakpoint for mobile
22
 * @type {number}
23
 */
24
const MOBILE_BREAKPOINT = 767;
24✔
25

26
/**
27
 * Enum options for mobile overflow beahvior
28
 * @type {Object.<string, string>}
29
 */
30
const MOBILE_OVERFLOW_BEHAVIOR_OPTION = {
24✔
31
  COLLAPSE: 'COLLAPSE',
32
  INNERSCROLL: 'INNERSCROLL'
33
};
34

35
/**
36
 * The Tab is a model that is used to power the Navigation tabs in the view.
37
 * It's initialized through the configuration provided to the component.
38
 */
39
export class Tab {
40
  constructor (config) {
41
    /**
42
     * The name of the tab that is exposed for the link
43
     * @type {string}
44
     */
45
    this.label = config.label;
15✔
46
    if (typeof this.label !== 'string') {
15!
47
      throw new AnswersComponentError('label is a required configuration option for tab.', 'NavigationComponent');
×
48
    }
49

50
    /**
51
     * The complete URL, including the params
52
     * @type {string}
53
     */
54
    this.url = config.url;
15✔
55
    if (typeof this.url !== 'string') {
15!
56
      throw new AnswersComponentError('url is a required configuration option for tab.', 'NavigationComponent');
×
57
    }
58

59
    /**
60
     * The serverside vertical config id that this is referenced to.
61
     * By providing this, enables dynamic sorting based on results.
62
     * @type {string}
63
     */
64
    this.verticalKey = config.verticalKey || null;
15✔
65

66
    /**
67
     * The base URL used for constructing the URL with params
68
     * @type {string}
69
     */
70
    this.baseUrl = config.url;
15✔
71

72
    /**
73
     * Determines whether to show this tab first in the order
74
     * @type {boolean}
75
     */
76
    this.isFirst = config.isFirst || false;
15✔
77

78
    /**
79
     * Determines whether or not to apply a special class to the
80
     * markup to determine if it's an active tab
81
     * @type {boolean}
82
     */
83
    this.isActive = config.isActive || false;
15✔
84
  }
85

86
  /**
87
   * from will construct a map of verticalKey to {Tab} from
88
   * a configuration file
89
   * @param {object} tabsConfig the configuration to use
90
   */
91
  static from (tabsConfig) {
92
    const tabs = {};
6✔
93
    // Parse the options and build out our tabs and
94
    for (let i = 0; i < tabsConfig.length; i++) {
6✔
95
      const tab = { ...tabsConfig[i] };
14✔
96

97
      // If a tab is configured to be hidden in this component,
98
      // do not process it
99
      if (tab.hideInNavigation) {
14✔
100
        continue;
1✔
101
      }
102

103
      // For tabs without config ids, map their URL to the configID
104
      // to avoid duplication of renders
105
      if (!tab.verticalKey && !tabs[tab.url]) {
13✔
106
        tab.verticalKey = tab.url;
3✔
107
      }
108

109
      tabs[tab.verticalKey] = new Tab(tab);
13✔
110
    }
111
    return tabs;
6✔
112
  }
113
}
114

115
/**
116
 * NavigationComponent exposes an interface for building a dynamic
117
 * navigation that is powered by universal search updates.
118
 * @extends Component
119
 */
120
export default class NavigationComponent extends Component {
121
  constructor (config = {}, systemConfig = {}) {
×
122
    super(config, systemConfig);
5✔
123

124
    /**
125
     * The label to show on the dropdown menu button when overflow
126
     * @type {string}
127
     */
128
    this.overflowLabel = config.overflowLabel || TranslationFlagger.flag({
5✔
129
      phrase: 'More',
130
      context: 'Button label, displays more items'
131
    });
132

133
    /**
134
     * The optional icon to show on the dropdown menu button when overflow
135
     * @type {string}
136
     */
137
    this.overflowIcon = config.overflowIcon || 'kabob';
5✔
138

139
    /**
140
     * The data storage key
141
     * @type {string}
142
     */
143
    this.moduleId = StorageKeys.NAVIGATION;
5✔
144

145
    /**
146
     * Tabs config from global navigation config
147
     * @type {Array.<object>}
148
     * @private
149
     */
150
    this._tabsConfig = config.verticalPages ||
5✔
151
      this.core.storage.get(StorageKeys.VERTICAL_PAGES_CONFIG).get();
152

153
    /**
154
     * Unordered map of each tab, keyed by VS verticalKey
155
     * @type {Object.<String, Object>}
156
     * @private
157
     */
158
    this._tabs = Tab.from(this._tabsConfig);
5✔
159

160
    /**
161
     * The order of the tabs, parsed from configuration or URL.
162
     * This gets updated based on the server results
163
     * @type {Array.<String>} The list of VS verticalKeys
164
     * @private
165
     */
166
    this._tabOrder = getDefaultTabOrder(
5✔
167
      this._tabsConfig, getUrlParams(this.core.storage.getCurrentStateUrlMerged()));
168

169
    /**
170
     * Breakpoints at which navigation items move to the "more" dropdown
171
     * @type {number[]}
172
     * @private
173
     */
174
    this._navBreakpoints = [];
5✔
175

176
    /**
177
     *  The mobile overflow behavior config
178
     *  @type {string}
179
     */
180
    this._mobileOverflowBehavior = config.mobileOverflowBehavior || MOBILE_OVERFLOW_BEHAVIOR_OPTION.COLLAPSE;
5✔
181

182
    /**
183
     *  The ARIA label
184
     *  @type {string}
185
     */
186
    this._ariaLabel = config.ariaLabel || TranslationFlagger.flag({
5✔
187
      phrase: 'Search Page Navigation',
188
      context: 'Noun, labels the navigation for the search page'
189
    });
190

191
    this.checkOutsideClick = this.checkOutsideClick.bind(this);
5✔
192
    this.checkMobileOverflowBehavior = this.checkMobileOverflowBehavior.bind(this);
5✔
193

194
    const reRender = () => {
5✔
195
      this.setState(this.core.storage.get(StorageKeys.NAVIGATION) || {});
1✔
196
    };
197

198
    this.core.storage.registerListener({
5✔
199
      eventType: 'update',
200
      storageKey: StorageKeys.API_CONTEXT,
201
      callback: reRender
202
    });
203
    this.core.storage.registerListener({
5✔
204
      eventType: 'update',
205
      storageKey: StorageKeys.SESSIONS_OPT_IN,
206
      callback: reRender
207
    });
208
  }
209

210
  static get type () {
211
    return 'Navigation';
29✔
212
  }
213

214
  /**
215
   * The template to render
216
   * @returns {string}
217
   * @override
218
   */
219
  static defaultTemplateName (config) {
220
    return 'navigation/navigation';
5✔
221
  }
222

223
  onCreate () {
224
    // TODO: Re-rendering and re-mounting the component every tim e the window changes size
225
    // is not great.
226
    DOM.on(window, 'resize', this.checkMobileOverflowBehavior);
5✔
227
  }
228

229
  onDestroy () {
230
    DOM.off(window, 'resize', this.checkMobileOverflowBehavior);
×
231
  }
232

233
  onMount () {
234
    if (this.shouldCollapse()) {
7!
235
      this._navBreakpoints = [];
7✔
236
      this.bindOverflowHandlers();
7✔
237
      this.refitNav();
7✔
238
      DOM.on(DOM.query(this._container, '.yxt-Nav-more'), 'click', this.toggleMoreDropdown.bind(this));
7✔
239
    }
240
    const navLinks = DOM.queryAll(this._container, '[data-originalurl]');
7✔
241
    navLinks.forEach(link => {
7✔
242
      const originalUrl = link.dataset.originalurl;
×
243
      if (originalUrl) {
×
244
        DOM.on(link, 'click', e => {
×
245
          if (e.metaKey || e.ctrlKey) {
×
246
            return;
×
247
          }
248
          e.preventDefault();
×
249
          window.open(originalUrl, '_self');
×
250
        });
251
      }
252
    });
253
  }
254

255
  setParentUrl (parentUrl) {
256
    this._parentUrl = parentUrl;
×
257
    this.setState(this.core.storage.get(StorageKeys.NAVIGATION) || {});
×
258
  }
259

260
  onUnMount () {
261
    this.unbindOverflowHandlers();
2✔
262
  }
263

264
  bindOverflowHandlers () {
265
    DOM.on(window, 'click', this.checkOutsideClick);
7✔
266
  }
267

268
  unbindOverflowHandlers () {
269
    DOM.off(window, 'click', this.checkOutsideClick);
2✔
270
  }
271

272
  refitNav () {
273
    const container = DOM.query(this._container, '.yxt-Nav-container');
7✔
274
    const moreButton = DOM.query(this._container, '.yxt-Nav-more');
7✔
275
    const mainLinks = DOM.query(this._container, '.yxt-Nav-expanded');
7✔
276
    const collapsedLinks = DOM.query(this._container, '.yxt-Nav-modal');
7✔
277

278
    const navWidth = moreButton.classList.contains('yxt-Nav-item--more')
7!
279
      ? container.offsetWidth
280
      : container.offsetWidth - moreButton.offsetWidth;
281
    let numBreakpoints = this._navBreakpoints.length;
7✔
282

283
    // sum child widths instead of using parent's width to avoid
284
    // browser inconsistencies
285
    let mainLinksWidth = 0;
7✔
286
    for (const el of mainLinks.children) {
7✔
287
      mainLinksWidth += el.offsetWidth;
16✔
288
    }
289

290
    if (mainLinksWidth > navWidth) {
7!
291
      this._navBreakpoints.push(mainLinksWidth);
×
292
      const lastLink = mainLinks.children.item(mainLinks.children.length - 1);
×
293
      if (lastLink === null) {
×
294
        return;
×
295
      }
296
      this._prepend(collapsedLinks, lastLink);
×
297

298
      if (moreButton.classList.contains('yxt-Nav-item--more')) {
×
299
        moreButton.classList.remove('yxt-Nav-item--more');
×
300
      }
301
    } else {
302
      if (numBreakpoints && navWidth > this._navBreakpoints[numBreakpoints - 1]) {
7!
303
        const firstLink = collapsedLinks.children.item(0);
×
304
        if (firstLink === null) {
×
305
          return;
×
306
        }
307
        mainLinks.append(firstLink);
×
308
        this._navBreakpoints.pop();
×
309
        numBreakpoints--;
×
310
      }
311

312
      if (collapsedLinks.children.length === 0) {
7!
313
        moreButton.classList.add('yxt-Nav-item--more');
7✔
314
      }
315
    }
316

317
    this.closeMoreDropdown();
7✔
318
    if (mainLinksWidth > navWidth ||
7!
319
      (numBreakpoints > 0 && navWidth > this._navBreakpoints[numBreakpoints - 1])) {
320
      this.refitNav();
×
321
    }
322
  }
323

324
  closeMoreDropdown () {
325
    const collapsed = DOM.query(this._container, '.yxt-Nav-modal');
7✔
326
    collapsed.classList.remove('is-active');
7✔
327
    const moreButton = DOM.query(this._container, '.yxt-Nav-more');
7✔
328
    moreButton.setAttribute('aria-expanded', false);
7✔
329
  }
330

331
  openMoreDropdown () {
332
    const collapsed = DOM.query(this._container, '.yxt-Nav-modal');
×
333
    collapsed.classList.add('is-active');
×
334
    const moreButton = DOM.query(this._container, '.yxt-Nav-more');
×
335
    moreButton.setAttribute('aria-expanded', true);
×
336
  }
337

338
  toggleMoreDropdown () {
339
    const collapsed = DOM.query(this._container, '.yxt-Nav-modal');
×
340
    collapsed.classList.toggle('is-active');
×
341
    const moreButton = DOM.query(this._container, '.yxt-Nav-more');
×
342
    moreButton.setAttribute('aria-expanded', collapsed.classList.contains('is-active'));
×
343
  }
344

345
  checkOutsideClick (e) {
346
    if (this._closest(e.target, '.yxt-Nav-container')) {
×
347
      return;
×
348
    }
349

350
    this.closeMoreDropdown();
×
351
  }
352

353
  checkMobileOverflowBehavior () {
354
    if (this._checkMobileOverflowBehaviorTimer) {
×
355
      clearTimeout(this._checkMobileOverflowBehaviorTimer);
×
356
    }
357

358
    this._checkMobileOverflowBehaviorTimer = setTimeout(this.setState.bind(this), RESIZE_DEBOUNCE);
×
359
  }
360

361
  /**
362
   * Since the server data only provides a list of
363
   * VS verticalKeys, we need to compute and transform
364
   * the data into the proper format for rendering.
365
   *
366
   * @override
367
   */
368
  setState (data = {}) {
×
369
    if (data.tabOrder !== undefined) {
7✔
370
      this._tabOrder = mergeTabOrder(data.tabOrder, this._tabOrder, this._tabs);
1✔
371
    }
372

373
    const params = getUrlParams(this.core.storage.getCurrentStateUrlMerged());
7✔
374
    params.set('tabOrder', this._tabOrder);
7✔
375
    const context = this.core.storage.get(StorageKeys.API_CONTEXT);
7✔
376
    if (context) {
7✔
377
      params.set(StorageKeys.API_CONTEXT, context);
2✔
378
    }
379
    const referrerPageUrl = this.core.storage.get(StorageKeys.REFERRER_PAGE_URL);
7✔
380
    if (referrerPageUrl !== undefined) {
7✔
381
      params.set(StorageKeys.REFERRER_PAGE_URL, referrerPageUrl);
1✔
382
    }
383

384
    const filteredParams = filterParamsForExperienceLink(
7✔
385
      params,
386
      types => this.componentManager.getComponentNamesForComponentTypes(types)
14✔
387
    );
388

389
    // Since the tab ordering can change based on the server data
390
    // we need to update each tabs URL to include the order as part of their params.
391
    // This helps with persisting state across verticals.
392
    const tabs = [];
7✔
393
    for (let i = 0; i < this._tabOrder.length; i++) {
7✔
394
      const tab = this._tabs[this._tabOrder[i]];
16✔
395
      if (tab !== undefined) {
16!
396
        tab.url = replaceUrlParams(tab.baseUrl, filteredParams);
16✔
397
        tabs.push(tab);
16✔
398
      }
399
    }
400

401
    if (this._parentUrl) {
7!
402
      const parentUrlWithoutParams = this._parentUrl.split('?')[0];
×
403
      const urlParser = document.createElement('a');
×
404
      tabs.forEach(tab => {
×
405
        tab.originalUrl = tab.url;
×
406
        urlParser.href = tab.url;
×
407
        const tabParams = new SearchParams(urlParser.search);
×
408
        const verticalUrl = urlParser.pathname.replace(/^\//, '');
×
409
        tabParams.set('verticalUrl', verticalUrl);
×
410
        tab.url = parentUrlWithoutParams + '?' + tabParams.toString();
×
411
        tab.target = '_parent';
×
412
      });
413
    }
414

415
    return super.setState({
7✔
416
      tabs: tabs,
417
      overflowLabel: this.overflowLabel,
418
      overflowIcon: this.overflowIcon,
419
      showCollapse: this.shouldCollapse(),
420
      ariaLabel: this._ariaLabel
421
    });
422
  }
423

424
  // TODO (agrow) investigate removing this
425
  // ParentNode.prepend polyfill
426
  // https://developer.mozilla.org/en-US/docs/Web/API/ParentNode/prepend#Polyfill
427
  _prepend (collapsedLinks, lastLink) {
428
    if (!collapsedLinks.hasOwnProperty('prepend')) { // eslint-disable-line no-prototype-builtins
×
429
      const docFrag = document.createDocumentFragment();
×
430
      const isNode = lastLink instanceof Node;
×
431
      docFrag.appendChild(isNode ? lastLink : document.createTextNode(String(lastLink)));
×
432

433
      collapsedLinks.insertBefore(docFrag, collapsedLinks.firstChild);
×
434
      return;
×
435
    }
436

437
    collapsedLinks.prepend(lastLink);
×
438
  }
439

440
  // TODO (agrow) investigate removing this
441
  // Adapted from Element.closest polyfill
442
  // https://developer.mozilla.org/en-US/docs/Web/API/Element/closest#Polyfill
443
  _closest (el, closestElSelector) {
444
    if (!el.hasOwnProperty('closest')) { // eslint-disable-line no-prototype-builtins
×
445
      do {
×
446
        if (DOM.matches(el, closestElSelector)) return el;
×
447
        el = el.parentElement || el.parentNode;
×
448
      } while (el !== null && el.nodeType === 1);
×
449
      return null;
×
450
    }
451
    return el.closest(closestElSelector);
×
452
  }
453

454
  shouldCollapse () {
455
    switch (this._mobileOverflowBehavior) {
14!
456
      case MOBILE_OVERFLOW_BEHAVIOR_OPTION.COLLAPSE:
457
        return true;
14✔
458
      case MOBILE_OVERFLOW_BEHAVIOR_OPTION.INNERSCROLL: {
459
        const container = DOM.query(this._container, '.yxt-Nav-container') || this._container;
×
460
        const navWidth = container.offsetWidth;
×
461
        return navWidth > MOBILE_BREAKPOINT;
×
462
      }
463
    }
464
  }
465
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc