/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
import { Controller } from "@hotwired/stimulus";
import { autocomplete } from '@algolia/autocomplete-js';
//import "@algolia/autocomplete-theme-classic/dist/theme.css";
import "../../css/components/autocomplete_bootstrap_theme.css";
import { createLocalStorageRecentSearchesPlugin } from '@algolia/autocomplete-plugin-recent-searches';
import {marked} from "marked";
import {
trans,
} from '../../translator';
/**
* This controller is responsible for the search fields in the navbar and the homepage.
* It uses the Algolia Autocomplete library to provide a fast and responsive search.
*/
export default class extends Controller {
static targets = ["input"];
_autocomplete;
// Highlight the search query in the results
_highlight = (text, query) => {
if (!text) return text;
if (!query) return text;
const HIGHLIGHT_PRE_TAG = '__aa-highlight__'
const HIGHLIGHT_POST_TAG = '__/aa-highlight__'
const escaped = query.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
const regex = new RegExp(escaped, 'gi');
return text.replace(regex, (match) => `${HIGHLIGHT_PRE_TAG}${match}${HIGHLIGHT_POST_TAG}`);
}
initialize() {
// The endpoint for searching parts or assemblies
const base_url = this.element.dataset.autocomplete;
// The URL template for the part detail pages
const part_detail_uri_template = this.element.dataset.detailUrl;
// The URL template for the assembly detail pages
const assembly_detail_uri_template = this.element.dataset.assemblyDetailUrl;
// The URL template for the project detail pages
const project_detail_uri_template = this.element.dataset.projectDetailUrl;
const hasAssemblyDetailUrl =
typeof assembly_detail_uri_template === "string" && assembly_detail_uri_template.length > 0;
const hasProjectDetailUrl =
typeof project_detail_uri_template === "string" && project_detail_uri_template.length > 0;
//The URL of the placeholder picture
const placeholder_image = this.element.dataset.placeholderImage;
//If the element is in navbar mode, or not
const navbar_mode = this.element.dataset.navbarMode === "true";
const that = this;
const recentSearchesPlugin = createLocalStorageRecentSearchesPlugin({
key: 'RECENT_SEARCH',
limit: 5,
});
// Cache the last query to avoid fetching the same endpoint twice (parts source + assemblies source)
let lastQuery = null;
let lastFetchPromise = null;
const fetchMixedItems = (query) => {
if (query === lastQuery && lastFetchPromise) {
return lastFetchPromise;
}
lastQuery = query;
const urlString = base_url.replace('__QUERY__', encodeURIComponent(query));
const url = new URL(urlString, window.location.href);
if (hasAssemblyDetailUrl || hasProjectDetailUrl) {
url.searchParams.set('multidatasources', '1');
}
lastFetchPromise = fetch(url.toString())
.then((response) => response.json())
.then((items) => {
//Iterate over all fields besides the id and highlight them (if present)
const fields = ["name", "description", "category", "footprint"];
items.forEach((item) => {
for (const field of fields) {
if (item[field] !== undefined && item[field] !== null) {
item[field] = that._highlight(item[field], query);
}
}
});
return items;
});
return lastFetchPromise;
};
this._autocomplete = autocomplete({
container: this.element,
//Place the panel in the navbar, if the element is in navbar mode
panelContainer: navbar_mode ? document.getElementById("navbar-search-form") : document.body,
panelPlacement: this.element.dataset.panelPlacement,
plugins: [recentSearchesPlugin],
openOnFocus: true,
placeholder: trans("search.placeholder"),
translations: {
submitButtonTitle: trans("search.submit")
},
// Use a navigator compatible with turbo:
navigator: {
navigate({ itemUrl }) {
window.Turbo.visit(itemUrl, { action: "advance" });
},
navigateNewTab({ itemUrl }) {
const windowReference = window.open(itemUrl, '_blank', 'noopener');
if (windowReference) {
windowReference.focus();
}
},
navigateNewWindow({ itemUrl }) {
window.open(itemUrl, '_blank', 'noopener');
},
},
// If the form is submitted, forward the term to the form
onSubmit({ state, event, ...setters }) {
//Put the current text into each target input field
const input = that.inputTarget;
if (!input) {
return;
}
//Do not submit the form, if the input is empty
if (state.query === "") {
return;
}
input.value = state.query;
input.form.requestSubmit();
},
getSources({ query }) {
const sources = [
// Parts source (filtered from mixed endpoint results)
{
sourceId: 'parts',
getItems() {
return fetchMixedItems(query).then((items) =>
items.filter((item) => item.type !== "assembly")
);
},
getItemUrl({ item }) {
return part_detail_uri_template.replace('__ID__', item.id);
},
templates: {
header({ html }) {
return html`
`;
},
item({ item, components, html }) {
const details_url = part_detail_uri_template.replace('__ID__', item.id);
return html`
${components.Highlight({hit: item, attribute: 'name'})}
${components.Highlight({hit: item, attribute: 'description'})}
${item.category ? html`
${components.Highlight({hit: item, attribute: 'category'})}
` : ""}
${item.footprint ? html`
${components.Highlight({hit: item, attribute: 'footprint'})}
` : ""}
`;
},
},
},
];
if (hasAssemblyDetailUrl) {
sources.push(
// Assemblies source (filtered from the same mixed endpoint results)
{
sourceId: 'assemblies',
getItems() {
return fetchMixedItems(query).then((items) =>
items.filter((item) => item.type === "assembly")
);
},
getItemUrl({ item }) {
return assembly_detail_uri_template.replace('__ID__', item.id);
},
templates: {
header({ html }) {
return html`
`;
},
item({ item, components, html }) {
const details_url = assembly_detail_uri_template.replace('__ID__', item.id);
return html`
${components.Highlight({hit: item, attribute: 'name'})}
${components.Highlight({hit: item, attribute: 'description'})}
`;
},
},
}
);
}
if (hasProjectDetailUrl) {
sources.push(
// Projects source (filtered from the same mixed endpoint results)
{
sourceId: 'projects',
getItems() {
return fetchMixedItems(query).then((items) =>
items.filter((item) => item.type === "project")
);
},
getItemUrl({ item }) {
return project_detail_uri_template.replace('__ID__', item.id);
},
templates: {
header({ html }) {
return html`
`;
},
item({ item, components, html }) {
const details_url = project_detail_uri_template.replace('__ID__', item.id);
return html`
${components.Highlight({hit: item, attribute: 'name'})}
${components.Highlight({hit: item, attribute: 'description'})}
`;
},
},
}
);
}
return sources;
},
});
//Try to find the input field and register a defocus handler. This is necessarry, as by default the autocomplete
//lib has problems when multiple inputs are present on the page. (see https://github.com/algolia/autocomplete/issues/1216)
const inputs = this.element.getElementsByClassName('aa-Input');
for (const input of inputs) {
input.addEventListener('blur', () => {
this._autocomplete.setIsOpen(false);
});
}
}
}