Skip to content

Commit

Permalink
IBX-6413: As an Editor, I'd to see autocomplete in global search (#901)
Browse files Browse the repository at this point in the history
Co-authored-by: Krzysztof Słomka <[email protected]>
Co-authored-by: Łukasz Ostafin <[email protected]>
  • Loading branch information
3 people authored Nov 17, 2023
1 parent 5be86ac commit 61a6687
Show file tree
Hide file tree
Showing 21 changed files with 622 additions and 96 deletions.
8 changes: 8 additions & 0 deletions src/bundle/Resources/config/services/components/layout.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,11 @@ services:
$template: '@@ibexadesign/ui/user_menu.html.twig'
tags:
- { name: ibexa.admin_ui.component, group: user-menu }

ibexa.search.autocomplete.content_template:
parent: Ibexa\AdminUi\Component\TabsComponent
arguments:
$template: '@@ibexadesign/ui/global_search_autocomplete_content_template.html.twig'
$groupIdentifier: 'global-search-autocomplete-content'
tags:
- { name: ibexa.admin_ui.component, group: global-search-autocomplete-templates }
7 changes: 7 additions & 0 deletions src/bundle/Resources/config/services/ui_config/common.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,10 @@ services:
Ibexa\AdminUi\UI\Config\Provider\CurrentBackOfficePath:
tags:
- { name: ibexa.admin_ui.config.provider, key: 'backOfficePath' }

Ibexa\AdminUi\UI\Config\Provider\SuggestionSetting:
arguments:
$minQueryLength: '%ibexa.site_access.config.default.search.suggestion.min_query_length%'
$resultLimit: '%ibexa.site_access.config.default.search.suggestion.result_limit%'
tags:
- { name: ibexa.admin_ui.config.provider, key: 'suggestions' }
3 changes: 3 additions & 0 deletions src/bundle/Resources/encore/ibexa.js.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const layout = [
path.resolve(__dirname, '../public/js/scripts/helpers/form.validation.helper.js'),
path.resolve(__dirname, '../public/js/scripts/helpers/form.error.helper.js'),
path.resolve(__dirname, '../public/js/scripts/helpers/system.helper.js'),
path.resolve(__dirname, '../public/js/scripts/helpers/highlight.helper.js'),
path.resolve(__dirname, '../public/js/scripts/admin.format.date.js'),
path.resolve(__dirname, '../public/js/scripts/core/draggable.js'),
path.resolve(__dirname, '../public/js/scripts/core/dropdown.js'),
Expand Down Expand Up @@ -69,6 +70,8 @@ const layout = [
path.resolve(__dirname, '../public/js/scripts/embedded.item.actions'),
path.resolve(__dirname, '../public/js/scripts/widgets/flatpickr.js'),
path.resolve(__dirname, '../public/js/scripts/admin.form.tabs.validation.js'),
path.resolve(__dirname, '../public/js/scripts/admin.search.autocomplete.js'),
path.resolve(__dirname, '../public/js/scripts/admin.search.autocomplete.content.js'),
];
const fieldTypes = [];

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
(function (global, doc, ibexa, Routing) {
const globalSearch = doc.querySelector('.ibexa-global-search');

if (!globalSearch) {
return;
}

const { escapeHTML } = ibexa.helpers.text;
const { highlightText } = ibexa.helpers.highlight;
const { getContentTypeIconUrl, getContentTypeName } = ibexa.helpers.contentType;
const autocompleteListNode = globalSearch.querySelector('.ibexa-global-search__autocomplete-list');
const autocompleteContentTemplateNode = globalSearch.querySelector('.ibexa-global-search__autocomplete-content-template');
const renderItem = (result, searchText) => {
const { locationId, contentId, name, contentTypeIdentifier, pathString, parentLocations } = result;
const pathArray = pathString.split('/').filter((id) => id);

const breadcrumb = pathArray.reduce((total, pathLocationId, index) => {
const parentLocation = parentLocations.find((parent) => parent.locationId === parseInt(pathLocationId, 10));

if (parseInt(pathLocationId, 10) === locationId) {
return total;
}

return index === 0 ? parentLocation.name : `${total} / ${parentLocation.name}`;
}, '');

const autocompleteItemTemplate = autocompleteContentTemplateNode.dataset.templateItem;
const autocompleteHighlightTemplate = autocompleteListNode.dataset.templateHighlight;
const renderedTemplate = autocompleteItemTemplate
.replace('{{ contentName }}', highlightText(searchText, name, autocompleteHighlightTemplate))
.replace('{{ iconHref }}', getContentTypeIconUrl(contentTypeIdentifier))
.replace('{{ contentTypeName }}', escapeHTML(getContentTypeName(contentTypeIdentifier)))
.replaceAll('{{ contentBreadcrumbs }}', breadcrumb)
.replace('{{ contentHref }}', Routing.generate('ibexa.content.view', { contentId, locationId }));

return renderedTemplate;
};

ibexa.addConfig('autocomplete.renderers.content', renderItem, true);
})(window, document, window.ibexa, window.Routing);
148 changes: 148 additions & 0 deletions src/bundle/Resources/public/js/scripts/admin.search.autocomplete.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
(function (global, doc, ibexa, Routing, Translator) {
const globalSearch = doc.querySelector('.ibexa-global-search');
const { getJsonFromResponse } = ibexa.helpers.request;
const { showErrorNotification } = ibexa.helpers.notification;
const { minQueryLength, resultLimit } = ibexa.adminUiConfig.suggestions;

if (!globalSearch) {
return;
}

const globalSearchInput = globalSearch.querySelector('.ibexa-global-search__input');
const clearBtn = globalSearch.querySelector(' .ibexa-input-text-wrapper__action-btn--clear');
const autocompleteNode = globalSearch.querySelector('.ibexa-global-search__autocomplete');
const autocompleteListNode = globalSearch.querySelector('.ibexa-global-search__autocomplete-list');
let searchAbortController;
const showResults = (searchText, results) => {
const { renderers } = ibexa.autocomplete;
const fragment = doc.createDocumentFragment();

results.forEach((result) => {
const container = doc.createElement('ul');
const renderer = renderers[result.type];

if (!renderer) {
return;
}

const renderedTemplate = renderer(result, searchText);

container.insertAdjacentHTML('beforeend', renderedTemplate);

const listItemNode = container.querySelector('li');

fragment.append(listItemNode);
});

addClickOutsideEventListener();
addKeyboardEventListener();

autocompleteListNode.innerHTML = '';
autocompleteListNode.append(fragment);

window.ibexa.helpers.ellipsis.middle.parse(autocompleteListNode);

autocompleteNode.classList.remove('ibexa-global-search__autocomplete--hidden');
autocompleteNode.classList.toggle('ibexa-global-search__autocomplete--results-empty', results.length === 0);
};
const getAutocompleteList = (searchText) => {
const url = Routing.generate('ibexa.search.suggestion', { query: searchText, limit: resultLimit });
const request = new Request(url, {
mode: 'same-origin',
credentials: 'same-origin',
});

searchAbortController = new AbortController();

const { signal } = searchAbortController;

fetch(request, { signal })
.then(getJsonFromResponse)
.then(showResults.bind(this, searchText))
.catch((error) => {
if (error.name === 'AbortError') {
return;
}

showErrorNotification(
Translator.trans(/*@Desc("Cannot load suggestions")*/ 'autocomplete.request.error', {}, 'ibexa_search'),
);
});
};
const handleTyping = (event) => {
const searchText = event.currentTarget.value.trim();

searchAbortController?.abort();

if (searchText.length <= minQueryLength) {
hideAutocomplete();

return;
}

getAutocompleteList(searchText);
};
const handleClickOutside = ({ target }) => {
if (target.closest('.ibexa-global-search')) {
return;
}

hideAutocomplete();
};
const addClickOutsideEventListener = () => {
doc.body.addEventListener('click', handleClickOutside, false);
};
const removeClickOutsideEventListener = () => {
doc.body.removeEventListener('click', handleClickOutside, false);
};
const handleKeyboard = ({ code }) => {
const keyboardDispatcher = {
ArrowDown: handleArrowDown,
ArrowUp: handleArrowUp,
};

keyboardDispatcher[code]?.();
};
const handleArrowDown = () => {
const focusedItemElement = autocompleteListNode.querySelector('.ibexa-global-search__autocomplete-item-link:focus');
const focusedViewAllElement = autocompleteNode.querySelector('.ibexa-global-search__autocomplete-view-all .ibexa-btn:focus');

if (!focusedItemElement && !focusedViewAllElement) {
autocompleteListNode.firstElementChild.firstElementChild.focus();

return;
}

if (focusedItemElement?.parentElement?.nextElementSibling) {
focusedItemElement.parentElement.nextElementSibling.firstElementChild.focus();
} else {
autocompleteNode.querySelector('.ibexa-global-search__autocomplete-view-all .ibexa-btn').focus();
}
};
const handleArrowUp = () => {
const focusedItemElement = autocompleteListNode.querySelector('.ibexa-global-search__autocomplete-item-link:focus');
const focusedViewAllElement = autocompleteNode.querySelector('.ibexa-global-search__autocomplete-view-all .ibexa-btn:focus');

if (focusedViewAllElement) {
autocompleteListNode.lastElementChild.firstElementChild.focus();

return;
}

focusedItemElement?.parentElement?.previousElementSibling.firstElementChild.focus();
};
const addKeyboardEventListener = () => {
doc.body.addEventListener('keydown', handleKeyboard, false);
};
const removeKeyboardEventListener = () => {
doc.body.removeEventListener('keydown', handleKeyboard, false);
};
const hideAutocomplete = () => {
autocompleteNode.classList.add('ibexa-global-search__autocomplete--hidden');
removeClickOutsideEventListener();
removeKeyboardEventListener();
};

globalSearchInput.addEventListener('keyup', handleTyping, false);
clearBtn.addEventListener('click', hideAutocomplete, false);
})(window, document, window.ibexa, window.Routing, window.Translator);
2 changes: 1 addition & 1 deletion src/bundle/Resources/public/js/scripts/admin.search.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
(function (global, doc) {
const headerSearchInput = doc.querySelector('.ibexa-main-header__search');
const headerSearchInput = doc.querySelector('.ibexa-global-search__input');
const headerSearchSubmitBtn = doc.querySelector('.ibexa-main-header .ibexa-input-text-wrapper__action-btn--search');
const searchForm = doc.querySelector('.ibexa-search-form');
const searchInput = doc.querySelector('.ibexa-search-form__search-input');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
(function (global, doc, ibexa) {
let contentTypesDataMap = null;
let contentTypesDataMapByHref = null;

/**
* Creates map with content types identifiers as keys for faster lookup
Expand All @@ -16,6 +17,15 @@
return contentTypeDataMap;
}, {});

const createContentTypeDataMapByHref = () =>
Object.values(ibexa.adminUiConfig.contentTypes).reduce((contentTypeDataMapByHref, contentTypeGroup) => {
for (const contentTypeData of contentTypeGroup) {
contentTypeDataMapByHref[contentTypeData.href] = contentTypeData;
}

return contentTypeDataMapByHref;
}, {});

/**
* Returns an URL to a content type icon
*
Expand Down Expand Up @@ -56,8 +66,36 @@
return contentTypesDataMap[contentTypeIdentifier].name;
};

const getContentTypeIconUrlByHref = (contentTypeHref) => {
if (!contentTypesDataMapByHref) {
contentTypesDataMapByHref = createContentTypeDataMapByHref();
}

if (!contentTypeHref || !contentTypesDataMapByHref[contentTypeHref]) {
return null;
}

const iconUrl = contentTypesDataMapByHref[contentTypeHref].thumbnail;

return iconUrl;
};

const getContentTypeNameByHref = (contentTypeHref) => {
if (!contentTypesDataMapByHref) {
contentTypesDataMapByHref = createContentTypeDataMapByHref();
}

if (!contentTypeHref || !contentTypesDataMapByHref[contentTypeHref]) {
return null;
}

return contentTypesDataMapByHref[contentTypeHref].name;
};

ibexa.addConfig('helpers.contentType', {
getContentTypeIconUrl,
getContentTypeName,
getContentTypeIconUrlByHref,
getContentTypeNameByHref,
});
})(window, window.document, window.ibexa);
28 changes: 28 additions & 0 deletions src/bundle/Resources/public/js/scripts/helpers/highlight.helper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
(function (global, doc, ibexa) {
const { escapeHTML } = ibexa.helpers.text;
const highlightText = (searchText, string, template) => {
const stringLowerCase = string.toLowerCase();
const searchTextLowerCase = searchText.toLowerCase();
const matches = stringLowerCase.matchAll(searchTextLowerCase);
const stringArray = [];
let previousIndex = 0;

for (const match of matches) {
const endOfSearchTextIndex = match.index + searchText.length;
const renderedTemplate = template.replace('{{ highlightText }}', escapeHTML(string.slice(match.index, endOfSearchTextIndex)));

stringArray.push(escapeHTML(string.slice(previousIndex, match.index)));
stringArray.push(renderedTemplate);

previousIndex = match.index + searchText.length;
}

stringArray.push(escapeHTML(string.slice(previousIndex)));

return stringArray.join('');
};

ibexa.addConfig('helpers.highlight', {
highlightText,
});
})(window, window.document, window.ibexa);
1 change: 0 additions & 1 deletion src/bundle/Resources/public/scss/_filters.scss
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@

.ibexa-label {
font-size: $ibexa-text-font-size-medium;
font-weight: 600;
color: $ibexa-color-dark;
margin-top: calculateRem(16px);
margin-bottom: calculateRem(4px);
Expand Down
Loading

0 comments on commit 61a6687

Please sign in to comment.