From e5ea7ba3aa04d026c4a4f999e23f5dae3db7e6ad Mon Sep 17 00:00:00 2001 From: Sean Roberts Date: Tue, 8 Nov 2016 10:19:42 -0500 Subject: [PATCH] Tag suggestions after tag: facet input --- gulpfile.js | 7 +- h/assets.ini | 3 + .../autosuggest-dropdown-controller.js | 6 +- .../controllers/search-bar-controller.js | 275 ++++++++++++------ .../autosuggest-dropdown-controller-test.js | 2 - .../controllers/search-bar-controller-test.js | 126 +++++++- h/static/scripts/tests/util/string-test.js | 52 ++++ h/static/scripts/util/string.js | 43 +++ h/templates/activity/search.html.jinja2 | 8 + h/templates/panels/navbar.html.jinja2 | 2 + package.json | 1 + 11 files changed, 413 insertions(+), 112 deletions(-) diff --git a/gulpfile.js b/gulpfile.js index 0fd794cc730..e1356f90a9a 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -58,9 +58,10 @@ var vendorBundles = { jquery: ['jquery'], bootstrap: ['bootstrap'], raven: ['raven-js'], + unorm: ['unorm'], }; -var vendorModules = ['jquery', 'bootstrap', 'raven-js']; -var vendorNoParseModules = ['jquery']; +var vendorModules = ['jquery', 'bootstrap', 'raven-js', 'unorm']; +var vendorNoParseModules = ['jquery', 'unorm']; // Builds the bundles containing vendor JS code gulp.task('build-vendor-js', function () { @@ -252,7 +253,7 @@ function runKarma(baseConfig, opts, done) { client: { mocha: { grep: taskArgs.grep, - } + }, }, }; diff --git a/h/assets.ini b/h/assets.ini index 2b2dac166af..4dff2b093b4 100644 --- a/h/assets.ini +++ b/h/assets.ini @@ -22,6 +22,9 @@ site_js = header_js = scripts/header.bundle.js +search_js = + scripts/unorm.bundle.js + site_css = styles/site.css styles/icomoon.css diff --git a/h/static/scripts/controllers/autosuggest-dropdown-controller.js b/h/static/scripts/controllers/autosuggest-dropdown-controller.js index 2f2b64a10d2..252a899c24a 100644 --- a/h/static/scripts/controllers/autosuggest-dropdown-controller.js +++ b/h/static/scripts/controllers/autosuggest-dropdown-controller.js @@ -84,6 +84,10 @@ class AutosuggestDropdownController extends Controller { }); this._setList(configOptions.list); + + + // Public API + this.setHeader = this._setHeader; } update(newState, prevState){ @@ -206,7 +210,7 @@ class AutosuggestDropdownController extends Controller { if (selection){ this.options.onSelect(selection); - this._toggleSuggestionsVisibility(/*show*/false); + this._filterAndToggleVisibility(); this.setState({ activeId: null, }); diff --git a/h/static/scripts/controllers/search-bar-controller.js b/h/static/scripts/controllers/search-bar-controller.js index 87360583e58..5f280b3d45d 100644 --- a/h/static/scripts/controllers/search-bar-controller.js +++ b/h/static/scripts/controllers/search-bar-controller.js @@ -1,9 +1,16 @@ 'use strict'; +var escapeHtml = require('escape-html'); + var Controller = require('../base/controller'); var LozengeController = require('./lozenge-controller'); var AutosuggestDropdownController = require('./autosuggest-dropdown-controller'); var SearchTextParser = require('../util/search-text-parser'); +var stringUtil = require('../util/string'); + +const FACET_TYPE = 'FACET'; +const TAG_TYPE = 'TAG'; +const MAX_SUGGESTIONS = 5; /** * Controller for the search bar. @@ -15,107 +22,11 @@ class SearchBarController extends Controller { this._input = this.refs.searchBarInput; this._lozengeContainer = this.refs.searchBarLozenges; - let explanationList = [ - { - title: 'user:', - explanation: 'search by username', - }, - { - title: 'tag:', - explanation: 'search for annotations with a tag', - }, - { - title: 'url:', - explanation: 'see all annotations on a page', - }, - { - title: 'group:', - explanation: 'show annotations created in a group you are a member of', - }, - ]; - - var selectFacet = facet => { - this._input.value = facet; - - setTimeout(()=>{ - this._input.focus(); - }, 0); - }; - var getTrimmedInputValue = () => { return this._input.value.trim(); }; - new AutosuggestDropdownController( this._input, { - - list: explanationList, - - header: 'Narrow your search', - - classNames: { - container: 'search-bar__dropdown-menu-container', - header: 'search-bar__dropdown-menu-header', - list: 'search-bar__dropdown-menu', - item: 'search-bar__dropdown-menu-item', - activeItem: 'js-search-bar-dropdown-menu-item--active', - }, - - renderListItem: (listItem)=>{ - - let itemContents = ` ${listItem.title} `; - - if (listItem.explanation){ - itemContents += ` ${listItem.explanation} `; - } - - return itemContents; - }, - - listFilter: function(list, currentInput){ - - currentInput = (currentInput || '').trim(); - - return list.filter((item)=>{ - - if (!currentInput){ - return item; - } else if (currentInput.indexOf(':') > -1) { - return false; - } - return item.title.toLowerCase().indexOf(currentInput) >= 0; - - }).sort((a,b)=>{ - - // this sort functions intention is to - // sort partial matches as lower index match - // value first. Then let natural sort of the - // original list take effect if they have equal - // index values or there is no current input value - - if (!currentInput){ - return 0; - } - - let aIndex = a.title.indexOf(currentInput); - let bIndex = b.title.indexOf(currentInput); - - if (aIndex > bIndex){ - return 1; - } else if (aIndex < bIndex){ - return -1; - } - return 0; - }); - }, - - onSelect: (itemSelected)=>{ - selectFacet(itemSelected.title); - }, - - }); - - /** * Insert a hidden with an empty value into the search
. * @@ -207,12 +118,184 @@ class SearchBarController extends Controller { } }; + this._hiddenInput = insertHiddenInput(this.refs.searchBarForm); + let explanationList = [ + { + matchOn: 'user', + title: 'user:', + explanation: 'search by username', + }, + { + matchOn: 'tag', + title: 'tag:', + explanation: 'search for annotations with a tag', + }, + { + matchOn: 'url', + title: 'url:', + explanation: 'see all annotations on a page', + }, + { + matchOn: 'group', + title: 'group:', + explanation: 'show annotations created in a group you are a member of', + }, + ].map((item)=>{ return Object.assign(item, { type: FACET_TYPE}); }); + + // tagSuggestions are made available by the scoped template data. + // see search.html.jinja2 for definition + const tagSuggestionJSON = document.querySelector('.js-tag-suggestions'); + let tagSuggestions = []; + + if(tagSuggestionJSON){ + try{ + tagSuggestions = JSON.parse(tagSuggestionJSON.innerHTML.trim()); + }catch(e){ + console.error('Could not parse .js-tag-suggestions JSON content', e); + } + } + + let tagsList = ((tagSuggestions) || []).map((item)=>{ + return Object.assign(item, { + type: TAG_TYPE, + title: escapeHtml(item.tag), // make safe + matchOn: stringUtil.fold(stringUtil.normalize(item.tag)), + usageCount: item.count || 0, + }); + }); + + + this._suggestionsHandler = new AutosuggestDropdownController( this._input, { + + list: explanationList.concat(tagsList), + + header: 'Narrow your search', + + classNames: { + container: 'search-bar__dropdown-menu-container', + header: 'search-bar__dropdown-menu-header', + list: 'search-bar__dropdown-menu', + item: 'search-bar__dropdown-menu-item', + activeItem: 'js-search-bar-dropdown-menu-item--active', + }, + + renderListItem: (listItem)=>{ + + let itemContents = ` ${listItem.title} `; + + if (listItem.explanation){ + itemContents += ` ${listItem.explanation} `; + } + + return itemContents; + }, + + listFilter: (list, currentInput)=>{ + + currentInput = (currentInput || '').trim(); + + let typeFilter = currentInput.indexOf('tag:') === 0 ? TAG_TYPE : FACET_TYPE; + let inputFilter = stringUtil.fold(stringUtil.normalize(currentInput)); + + if(typeFilter === TAG_TYPE){ + inputFilter = currentInput.substr(/*'tag:' len*/4); + + // remove the initial quote for comparisons if it exists + if(inputFilter[0] === '\'' || inputFilter[0] === '"'){ + inputFilter = inputFilter.substr(1); + } + } + + if(this.state.suggestionsType !== typeFilter){ + this.setState({ + suggestionsType: typeFilter, + }); + } + + return list.filter((item)=>{ + return item.type === typeFilter && item.matchOn.toLowerCase().indexOf(inputFilter.toLowerCase()) >= 0; + }).sort((a,b)=>{ + + // this sort functions intention is to + // sort partial matches as lower index match + // value first. Then let natural sort of the + // original list take effect if they have equal + // index values or there is no current input value + + if (inputFilter){ + let aIndex = a.matchOn.indexOf(inputFilter); + let bIndex = b.matchOn.indexOf(inputFilter); + + // match score + if (aIndex > bIndex){ + return 1; + } else if (aIndex < bIndex){ + return -1; + } + } + + + // If we are filtering on tags, we need to arrange + // by popularity + if(typeFilter === TAG_TYPE){ + if(a.usageCount > b.usageCount){ + return -1; + }else if(a.usageCount < b.usageCount) { + return 1; + } + } + + return 0; + + }).slice(0, MAX_SUGGESTIONS); + }, + + onSelect: (itemSelected)=>{ + + if (itemSelected.type === TAG_TYPE){ + let tagSelection = itemSelected.title; + + // wrap multi word phrases with quotes to keep + // autosuggestions consistent with what user needs to do + if(tagSelection.indexOf(' ') > -1){ + tagSelection = `"${tagSelection}"`; + } + + addLozenge('tag:' + tagSelection); + this._input.value = ''; + } else { + this._input.value = itemSelected.title; + updateHiddenInput(); + setTimeout(()=>{ + this._input.focus(); + }, 0); + } + }, + + }); + this._input.addEventListener('keydown', onInputKeyDown); this._input.addEventListener('input', updateHiddenInput); lozengifyInput(); } + + update(newState, prevState){ + + if(!this._suggestionsHandler){ + return; + } + + if(newState.suggestionsType !== prevState.suggestionsType){ + if(newState.suggestionsType === TAG_TYPE){ + this._suggestionsHandler.setHeader('Popular tags'); + }else { + this._suggestionsHandler.setHeader('Narrow your search'); + } + } + + } } module.exports = SearchBarController; diff --git a/h/static/scripts/tests/controllers/autosuggest-dropdown-controller-test.js b/h/static/scripts/tests/controllers/autosuggest-dropdown-controller-test.js index c68ba9fd515..8c089d4986b 100644 --- a/h/static/scripts/tests/controllers/autosuggest-dropdown-controller-test.js +++ b/h/static/scripts/tests/controllers/autosuggest-dropdown-controller-test.js @@ -341,8 +341,6 @@ describe('AutosuggestDropdownController', function () { assert.propertyVal(selectedItem, 'title', 'tag:'); assert.propertyVal(selectedItem, 'explanation', 'search for annotations with a tag'); - assert.isFalse(isSuggestionContainerVisible(), 'post select hide'); - assert.isFalse(form.onsubmit.called, 'should not submit the form on enter'); done(); diff --git a/h/static/scripts/tests/controllers/search-bar-controller-test.js b/h/static/scripts/tests/controllers/search-bar-controller-test.js index da3b64eb4f5..583c547c178 100644 --- a/h/static/scripts/tests/controllers/search-bar-controller-test.js +++ b/h/static/scripts/tests/controllers/search-bar-controller-test.js @@ -22,7 +22,13 @@ describe('SearchBarController', function () { `; - beforeEach(function () { + var getItemTitles = function(){ + return Array.from(dropdown.querySelectorAll('.search-bar__dropdown-menu-title')).map((node)=>{ + return node.textContent.trim(); + }); + }; + + var setup = function(){ testEl = document.createElement('div'); testEl.innerHTML = TEMPLATE; document.body.appendChild(testEl); @@ -31,11 +37,60 @@ describe('SearchBarController', function () { input = ctrl.refs.searchBarInput; dropdown = input.nextSibling; - }); + }; - afterEach(function () { + var teardown = function(){ document.body.removeChild(testEl); - }); + let tagsJSON = document.querySelector('.js-tag-suggestions'); + if(tagsJSON){ + tagsJSON.remove(); + } + }; + + var addTagSuggestions = function(){ + let suggestions = [ + { + tag: 'aaaa', + count: 1, + }, + { + tag: 'aaab', + count: 1, + }, + { + tag: 'aaac', + count: 4, + }, + { + tag: 'aaad', + count: 3, + }, + { + tag: 'aaae', + count: 1, + }, + { + tag: 'aadf', + count: 3, + }, + { + tag: 'aaag', + count: 2, + }, + { + tag: 'multi word', + count: 1, + }, + ]; + + let tagsScript = document.createElement('script'); + tagsScript.innerHTML = JSON.stringify(suggestions); + tagsScript.className = 'js-tag-suggestions'; + document.body.appendChild(tagsScript); + }; + + beforeEach(setup); + afterEach(teardown); it('uses autosuggestion for initial facets', function (done) { @@ -45,11 +100,7 @@ describe('SearchBarController', function () { .click(input, () => { assert.isTrue(dropdown.classList.contains('is-open')); - let titles = Array.from(document.querySelectorAll('.search-bar__dropdown-menu-title')).map((node)=>{ - return node.textContent.trim(); - }); - - assert.deepEqual(titles, ['user:', 'tag:', 'url:', 'group:']); + assert.deepEqual(getItemTitles(), ['user:', 'tag:', 'url:', 'group:']); done(); }); @@ -70,7 +121,7 @@ describe('SearchBarController', function () { it('allows submitting the form dropdown is open but has no selected value', function (done) { let form = testEl.querySelector('form'); let submit = sinon.stub(form, 'submit'); - + syn .click(input) .type('test[space]', () => { @@ -83,6 +134,61 @@ describe('SearchBarController', function () { }); }); + describe('it allows tag value suggestions', function () { + + beforeEach(function(){ + // we need to setup the env vars before invoking controller + teardown(); + addTagSuggestions(); + setup(); + + sinon.stub(testEl.querySelector('form'), 'submit'); + }); + + it('shows tag suggestsions', function(done){ + syn + .click(input) + .type('tag:', () => { + assert.isTrue(dropdown.classList.contains('is-open')); + + let titles = getItemTitles(); + + assert.lengthOf(titles, 5, 'we should be enforcing the 5 item max'); + }) + .type('[backspace][backspace][backspace][backspace]', () => { + assert.deepEqual(getItemTitles(), [ 'user:', 'tag:', 'url:', 'group:' ], 'tags go away as facet is removed'); + done(); + }); + }); + + it('orders tags by priority and indexOf score', function(done){ + syn + .click(input) + .type('tag:', () => { + assert.deepEqual(getItemTitles(), [ 'aaac', 'aaad', 'aadf', 'aaag', 'aaaa' ], 'default ordering based on priority'); + }) + .type('aad', () => { + assert.deepEqual(getItemTitles(), [ 'aadf', 'aaad'], 'sorting by indexof score with equal priority'); + done(); + }); + }); + + it('orders tags by priority and indexOf score', function(done){ + syn + .click(input) + .type('tag:"mul', () => { + assert.deepEqual(getItemTitles(), [ 'multi word' ], 'supports matching on a double quote initial input'); + }) + .type('[backspace][backspace][backspace][backspace]\'mul', () => { + assert.deepEqual(getItemTitles(), [ 'multi word' ], 'supports matching on a single quote initial input'); + }) + .type('[down][enter][enter]', ()=>{ + assert.equal(testEl.querySelector('input[type=hidden]').value.trim(), 'tag:"multi word"', 'selecting a multi word tag should wrap with quotes'); + done(); + }); + }); + + }); }); describe('Lozenges', function () { diff --git a/h/static/scripts/tests/util/string-test.js b/h/static/scripts/tests/util/string-test.js index 0c666efccc2..4bff45b1e3b 100644 --- a/h/static/scripts/tests/util/string-test.js +++ b/h/static/scripts/tests/util/string-test.js @@ -18,4 +18,56 @@ describe('util/string', function () { assert.equal(stringUtil.unhyphenate('-foo-bar-baz'), 'FooBarBaz'); }); }); + + describe('stringUtil helpers', function(){ + + it('removes hungarian marks', function(){ + let text = 'Fürge rőt róka túlszökik zsíros étkű kutyán'; + let decoded = stringUtil.fold(stringUtil.normalize(text)); + let expected = 'Furge rot roka tulszokik zsiros etku kutyan'; + + assert.equal(decoded, expected); + }); + + it('removes greek marks', function(){ + let text = 'Καλημέρα κόσμε'; + let decoded = stringUtil.fold(stringUtil.normalize(text)); + let expected = 'Καλημερα κοσμε'; + + assert.equal(decoded, expected); + }); + + it('removes japanese marks', function(){ + let text = 'カタカナコンバータ'; + let decoded = stringUtil.fold(stringUtil.normalize(text)); + let expected = 'カタカナコンハータ'; + + assert.equal(decoded, expected); + }); + + it('removes marathi marks', function(){ + let text = 'काचं शक्नोम्यत्तुम'; + let decoded = stringUtil.fold(stringUtil.normalize(text)); + let expected = 'कच शकनमयततम'; + + assert.equal(decoded, expected); + }); + + it('removes thai marks', function(){ + let text = 'ฉันกินกระจกได้ แต่มันไม่ทำให้ฉันเจ็บ'; + let decoded = stringUtil.fold(stringUtil.normalize(text)); + let expected = 'ฉนกนกระจกได แตมนไมทาใหฉนเจบ'; + + assert.equal(decoded, expected); + }); + + it('removes all marks', function(){ + let text = '̀ ́ ̂ ̃ ̄ ̅ ̆ ̇ ̈ ̉ ̊ ̋ ̌ ̍ ̎ ̏ ̐ ̑ ̒ ̓ ̔ ̕ ̖ ̗ ̘ ̙ ̚ ̛ ̜ ̝ ̞ ̟ ̠ ̡ ̢ ̣ ̤ ̥ ̦ ̧ ̨ ̩ ̪ ̫ ̬ ̭ ̮ ̯ ̰ ̱ ̲ ̳ ̴ ̵ ̶ ̷ ̸ ̹ ̺ ̻ ̼ ̽ ̾ ̿ ̀ ́ ͂ ̓ ̈́ ͅ ͠ ͡"'; + let decoded = stringUtil.fold(stringUtil.normalize(text)); + let expected = ' "'; + + assert.equal(decoded, expected); + }); + + }); }); diff --git a/h/static/scripts/util/string.js b/h/static/scripts/util/string.js index 765bdf6b6f6..f20b420a595 100644 --- a/h/static/scripts/util/string.js +++ b/h/static/scripts/util/string.js @@ -1,5 +1,10 @@ 'use strict'; +// Unicode combining characters +// from http://xregexp.com/addons/unicode/unicode-categories.js line:30 +const COMBINING_MARKS = /[\u0300-\u036F\u0483-\u0489\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED\u0711\u0730-\u074A\u07A6-\u07B0\u07EB-\u07F3\u0816-\u0819\u081B-\u0823\u0825-\u0827\u0829-\u082D\u0859-\u085B\u08E4-\u08FE\u0900-\u0903\u093A-\u093C\u093E-\u094F\u0951-\u0957\u0962\u0963\u0981-\u0983\u09BC\u09BE-\u09C4\u09C7\u09C8\u09CB-\u09CD\u09D7\u09E2\u09E3\u0A01-\u0A03\u0A3C\u0A3E-\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A70\u0A71\u0A75\u0A81-\u0A83\u0ABC\u0ABE-\u0AC5\u0AC7-\u0AC9\u0ACB-\u0ACD\u0AE2\u0AE3\u0B01-\u0B03\u0B3C\u0B3E-\u0B44\u0B47\u0B48\u0B4B-\u0B4D\u0B56\u0B57\u0B62\u0B63\u0B82\u0BBE-\u0BC2\u0BC6-\u0BC8\u0BCA-\u0BCD\u0BD7\u0C01-\u0C03\u0C3E-\u0C44\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C62\u0C63\u0C82\u0C83\u0CBC\u0CBE-\u0CC4\u0CC6-\u0CC8\u0CCA-\u0CCD\u0CD5\u0CD6\u0CE2\u0CE3\u0D02\u0D03\u0D3E-\u0D44\u0D46-\u0D48\u0D4A-\u0D4D\u0D57\u0D62\u0D63\u0D82\u0D83\u0DCA\u0DCF-\u0DD4\u0DD6\u0DD8-\u0DDF\u0DF2\u0DF3\u0E31\u0E34-\u0E3A\u0E47-\u0E4E\u0EB1\u0EB4-\u0EB9\u0EBB\u0EBC\u0EC8-\u0ECD\u0F18\u0F19\u0F35\u0F37\u0F39\u0F3E\u0F3F\u0F71-\u0F84\u0F86\u0F87\u0F8D-\u0F97\u0F99-\u0FBC\u0FC6\u102B-\u103E\u1056-\u1059\u105E-\u1060\u1062-\u1064\u1067-\u106D\u1071-\u1074\u1082-\u108D\u108F\u109A-\u109D\u135D-\u135F\u1712-\u1714\u1732-\u1734\u1752\u1753\u1772\u1773\u17B4-\u17D3\u17DD\u180B-\u180D\u18A9\u1920-\u192B\u1930-\u193B\u19B0-\u19C0\u19C8\u19C9\u1A17-\u1A1B\u1A55-\u1A5E\u1A60-\u1A7C\u1A7F\u1B00-\u1B04\u1B34-\u1B44\u1B6B-\u1B73\u1B80-\u1B82\u1BA1-\u1BAD\u1BE6-\u1BF3\u1C24-\u1C37\u1CD0-\u1CD2\u1CD4-\u1CE8\u1CED\u1CF2-\u1CF4\u1DC0-\u1DE6\u1DFC-\u1DFF\u20D0-\u20F0\u2CEF-\u2CF1\u2D7F\u2DE0-\u2DFF\u302A-\u302F\u3099\u309A\uA66F-\uA672\uA674-\uA67D\uA69F\uA6F0\uA6F1\uA802\uA806\uA80B\uA823-\uA827\uA880\uA881\uA8B4-\uA8C4\uA8E0-\uA8F1\uA926-\uA92D\uA947-\uA953\uA980-\uA983\uA9B3-\uA9C0\uAA29-\uAA36\uAA43\uAA4C\uAA4D\uAA7B\uAAB0\uAAB2-\uAAB4\uAAB7\uAAB8\uAABE\uAABF\uAAC1\uAAEB-\uAAEF\uAAF5\uAAF6\uABE3-\uABEA\uABEC\uABED\uFB1E\uFE00-\uFE0F\uFE20-\uFE26]/g; + + /** * Convert a `camelCase` or `CapitalCase` string to `kebab-case` */ @@ -19,7 +24,45 @@ function unhyphenate(name) { } } + +/** + * Utility function to normalize a string based on our standard pattern of NFKD + * + * @param {String} + * @returns {String} + */ +function normalize(str){ + + // This require is coming from a vendor bundle + // that is loaded globally on the running webpage + // not a require in the node context + try{ + require('unorm'); + }catch(e){ + console.error('unorm not available'); + } + + if(!String.prototype.normalize){ + return str; + } + + return str.normalize('NFKD'); +} + + +/** + * Remove the combining marks available in Unicode strings + * + * @param {String} + * @returns {String} + */ +function fold(str){ + return str.replace(COMBINING_MARKS, ''); +} + module.exports = { hyphenate: hyphenate, unhyphenate: unhyphenate, + normalize: normalize, + fold: fold, }; diff --git a/h/templates/activity/search.html.jinja2 b/h/templates/activity/search.html.jinja2 index 174db26c26a..bbc53b6e6e8 100644 --- a/h/templates/activity/search.html.jinja2 +++ b/h/templates/activity/search.html.jinja2 @@ -82,6 +82,14 @@ {{ panel('navbar') }} + {% for url in asset_urls("search_js") %} + + {% endfor %} + + +
    {% for timeframe in timeframes %} diff --git a/h/templates/panels/navbar.html.jinja2 b/h/templates/panels/navbar.html.jinja2 index 4035e541e46..dc1d2c218fe 100644 --- a/h/templates/panels/navbar.html.jinja2 +++ b/h/templates/panels/navbar.html.jinja2 @@ -1,7 +1,9 @@ {% from '../includes/dropdown_menu.html.jinja2' import dropdown_menu %} {% block content %} + {% if feature('activity_pages') %} +