diff --git a/Makefile b/Makefile index 670e422f..f0f0c7be 100644 --- a/Makefile +++ b/Makefile @@ -176,6 +176,10 @@ build-translations: @echo "🔎 Building translations …" ${DOCKER_COMPOSE} run --rm search_nodejs npm run translations:build +cleanup-indexes: + @echo "🔎 Cleaning indexes …" + ${DOCKER_COMPOSE} run --rm api python3 -m app cleanup-indexes ${args} + generate-openapi: _ensure_network @echo "🔎 Generating OpenAPI spec …" ${DOCKER_COMPOSE} run --rm api python3 -m app export-openapi /opt/search/data/searchalicious-openapi.yml diff --git a/app/_import.py b/app/_import.py index 7789af50..968ce71a 100644 --- a/app/_import.py +++ b/app/_import.py @@ -12,8 +12,9 @@ from redis import Redis from app._types import FetcherResult, FetcherStatus, JSONType -from app.config import Config, IndexConfig, TaxonomyConfig, settings +from app.config import Config, IndexConfig, settings from app.indexing import ( + BaseTaxonomyPreprocessor, DocumentProcessor, generate_index_object, generate_taxonomy_index_object, @@ -252,7 +253,7 @@ def gen_documents( def gen_taxonomy_documents( - taxonomy_config: TaxonomyConfig, next_index: str, supported_langs: set[str] + config: IndexConfig, next_index: str, supported_langs: set[str] ): """Generator for taxonomy documents in Elasticsearch. @@ -261,26 +262,51 @@ def gen_taxonomy_documents( :param supported_langs: a set of supported languages :yield: a dict with the document to index, compatible with ES bulk API """ - for taxonomy_name, taxonomy in tqdm.tqdm(iter_taxonomies(taxonomy_config)): + taxonomy_config = config.taxonomy + preprocessor: BaseTaxonomyPreprocessor | None = None + if taxonomy_config.preprocessor: + preprocessor_cls = load_class_object_from_string(taxonomy_config.preprocessor) + preprocessor = preprocessor_cls(config) + for taxonomy in tqdm.tqdm(iter_taxonomies(taxonomy_config)): for node in taxonomy.iter_nodes(): + if preprocessor: + result = preprocessor.preprocess(taxonomy, node) + if result.status != FetcherStatus.FOUND or result.node is None: + continue # skip this entry + node = result.node names = { - lang: lang_names - for lang, lang_names in node.names.items() - if lang in supported_langs + lang: lang_name + for lang, lang_name in node.names.items() + if lang in supported_langs and lang_name } - synonyms = { - lang: lang_names - for lang, lang_names in node.synonyms.items() + synonyms: dict[str, set[str]] = { + lang: set(node.synonyms.get(lang) or []) + for lang in node.synonyms if lang in supported_langs } - + for lang, lang_name in names.items(): + if lang_name: + synonyms.setdefault(lang, set()).add(lang_name) + # put the name as first synonym and order by length + synonyms_list: dict[str, list[str]] = {} + for lang, lang_synonyms in synonyms.items(): + filtered_synonyms = filter(lambda s: s, lang_synonyms) + synonyms_list[lang] = sorted( + filtered_synonyms, key=lambda s: 0 if s == names[lang] else len(s) + ) yield { "_index": next_index, "_source": { "id": node.id, - "taxonomy_name": taxonomy_name, + "taxonomy_name": taxonomy.name, "name": names, - "synonyms": synonyms, + "synonyms": { + lang: { + "input": lang_synonyms, + "weight": max(100 - len(node.id), 0), + } + for lang, lang_synonyms in synonyms_list.items() + }, }, } @@ -370,7 +396,7 @@ def import_taxonomies(config: IndexConfig, next_index: str): success, errors = bulk( es, gen_taxonomy_documents( - config.taxonomy, next_index, supported_langs=set(config.supported_langs) + config, next_index, supported_langs=set(config.supported_langs) ), raise_on_error=False, ) diff --git a/app/api.py b/app/api.py index 6f6fcc47..de90cb88 100644 --- a/app/api.py +++ b/app/api.py @@ -149,8 +149,11 @@ def taxonomy_autocomplete( description="Name(s) of the taxonomy to search in, as a comma-separated value." ), ], - lang: Annotated[ - str, Query(description="Language to search in, defaults to 'en'.") + langs: Annotated[ + str, + Query( + description="Languages to search in (as a comma separated list), defaults to 'en'." + ), ] = "en", size: Annotated[int, Query(description="Number of results to return.")] = 10, fuzziness: Annotated[ @@ -167,7 +170,7 @@ def taxonomy_autocomplete( query = build_completion_query( q=q, taxonomy_names=taxonomy_names_list, - lang=lang, + langs=langs.split(","), size=size, config=index_config, fuzziness=fuzziness, @@ -180,7 +183,7 @@ def taxonomy_autocomplete( detail="taxonomy index not found, taxonomies need to be imported first", ) - response = process_taxonomy_completion_response(es_response) + response = process_taxonomy_completion_response(es_response, q, langs.split(",")) return { **response, diff --git a/app/config.py b/app/config.py index f820099b..c381d87b 100644 --- a/app/config.py +++ b/app/config.py @@ -510,6 +510,26 @@ class TaxonomyConfig(BaseModel): TaxonomyIndexConfig, Field(description=TaxonomyIndexConfig.__doc__), ] + preprocessor: ( + Annotated[ + str, + Field( + description=cd_( + """The full qualified reference to the preprocessor + to use before taxonomy entry import. + + This class must inherit `app.indexing.BaseTaxonomyPreprocessor` + and specialize the `preprocess` method. + + This is used to adapt the taxonomy schema + or to add specific fields for example. + """ + ), + examples=["app.openfoodfacts.TaxonomyPreprocessor"], + ), + ] + | None + ) = None class ScriptConfig(BaseModel): diff --git a/app/indexing.py b/app/indexing.py index 9d09f776..6a3050fd 100644 --- a/app/indexing.py +++ b/app/indexing.py @@ -9,12 +9,12 @@ from app._types import FetcherResult, FetcherStatus, JSONType from app.config import ( ANALYZER_LANG_MAPPING, - Config, FieldConfig, FieldType, IndexConfig, TaxonomyConfig, ) +from app.taxonomy import Taxonomy, TaxonomyNode, TaxonomyNodeResult from app.utils import load_class_object_from_string from app.utils.analyzers import ( get_autocomplete_analyzer, @@ -104,8 +104,41 @@ def preprocess_field_value( return input_value +class BaseTaxonomyPreprocessor(abc.ABC): + """Base class for taxonomy entries preprocessors. + + Classes referenced in index configuration `preprocess` field, + has to be derived from it. + """ + + def __init__(self, config: IndexConfig) -> None: + self.config = config + + @abc.abstractmethod + def preprocess(self, taxonomy: Taxonomy, node: TaxonomyNode) -> TaxonomyNodeResult: + """Preprocess the taxonomy entry before ingestion in Elasticsearch, + and before synonyms generation + + This can be used to make document schema compatible with the project + schema or to add custom fields. + + :return: a TaxonomyNodeResult object: + + * the status can be used to pilot wether + to index or not the entry (even delete it) + * the entry is the transformed entry + """ + pass + + class BaseDocumentPreprocessor(abc.ABC): - def __init__(self, config: Config) -> None: + """Base class for document preprocessors. + + Classes referenced in index configuration `preprocess` field, + has to be derived from it. + """ + + def __init__(self, config: IndexConfig) -> None: self.config = config @abc.abstractmethod @@ -119,7 +152,7 @@ def preprocess(self, document: JSONType) -> FetcherResult: * the status can be used to pilot wether to index or not the document (even delete it) - * the document is the document transformed document + * the document is the transformed document """ pass @@ -379,6 +412,7 @@ def generate_taxonomy_mapping_object(config: IndexConfig) -> Mapping: "type": "category", } ], + preserve_separators=False, # help match plurals ) for lang in supported_langs }, diff --git a/app/openfoodfacts.py b/app/openfoodfacts.py index 1a4adc6e..8b3e1fd1 100644 --- a/app/openfoodfacts.py +++ b/app/openfoodfacts.py @@ -7,8 +7,9 @@ from app._import import BaseDocumentFetcher from app._types import FetcherResult, FetcherStatus, JSONType -from app.indexing import BaseDocumentPreprocessor +from app.indexing import BaseDocumentPreprocessor, BaseTaxonomyPreprocessor from app.postprocessing import BaseResultProcessor +from app.taxonomy import Taxonomy, TaxonomyNode, TaxonomyNodeResult from app.utils.download import http_session from app.utils.log import get_logger @@ -87,6 +88,37 @@ def generate_image_url(code: str, image_id: str) -> str: OFF_API_URL = os.environ.get("OFF_API_URL", "https://world.openfoodfacts.org") +class TaxonomyPreprocessor(BaseTaxonomyPreprocessor): + """Preprocessor for Open Food Facts taxonomies.""" + + def preprocess(self, taxonomy: Taxonomy, node: TaxonomyNode) -> TaxonomyNodeResult: + """Preprocess a taxonomy node, + + We add the main language, and we also have specificities for some taxonomies + """ + if taxonomy.name == "brands": + # brands are english only, put them in "main lang" + node.names.update(main=node.names["en"]) + if node.synonyms and (synonyms_en := list(node.synonyms.get("en", []))): + node.synonyms.update(main=synonyms_en) + else: + # main language is entry id prefix + eventual xx entries + id_lang = node.id.split(":")[0] + if node_names := node.names.get(id_lang): + node.names.update(main=node_names) + node.synonyms.update(main=list(node.synonyms.get(id_lang, []))) + # add eventual xx entries as synonyms to all languages + xx_name = node.names.get("xx") + xx_names = [xx_name] if xx_name else [] + xx_names += node.synonyms.get("xx", []) + if xx_names: + for lang in self.config.supported_langs: + node.names.setdefault(lang, xx_names[0]) + lang_synonyms = node.synonyms.setdefault(lang, []) + lang_synonyms += xx_names + return TaxonomyNodeResult(status=FetcherStatus.FOUND, node=node) + + class DocumentFetcher(BaseDocumentFetcher): def fetch_document(self, stream_name: str, item: JSONType) -> FetcherResult: if item.get("action") == "deleted": diff --git a/app/postprocessing.py b/app/postprocessing.py index 40d0f87a..69ce897a 100644 --- a/app/postprocessing.py +++ b/app/postprocessing.py @@ -62,16 +62,32 @@ def load_result_processor(config: IndexConfig) -> BaseResultProcessor | None: return result_processor_cls(config) -def process_taxonomy_completion_response(response: Response) -> JSONType: +def process_taxonomy_completion_response( + response: Response, input: str, langs: list[str] +) -> JSONType: output = {"took": response.took, "timed_out": response.timed_out} options = [] - suggestion = response.suggest["taxonomy_suggest"][0] - for option in suggestion.options: - result = { - "id": option._source["id"], - "text": option.text, - "taxonomy_name": option._source["taxonomy_name"], - } - options.append(result) - output["options"] = options + ids = set() + lang = langs[0] + for suggestion_id in dir(response.suggest): + if not suggestion_id.startswith("taxonomy_suggest_"): + continue + for suggestion in getattr(response.suggest, suggestion_id): + for option in suggestion.options: + if option._source["id"] in ids: + continue + ids.add(option._source["id"]) + result = { + "id": option._source["id"], + "text": option.text, + "name": getattr(option._source["name"], lang, ""), + "score": option._score, + "input": input, + "taxonomy_name": option._source["taxonomy_name"], + } + options.append(result) + # highest score first + output["options"] = sorted( + options, key=lambda option: option["score"], reverse=True + ) return output diff --git a/app/query.py b/app/query.py index b5299902..6d9a7d87 100644 --- a/app/query.py +++ b/app/query.py @@ -322,7 +322,7 @@ def build_es_query( def build_completion_query( q: str, taxonomy_names: list[str], - lang: str, + langs: list[str], size: int, config: IndexConfig, fuzziness: int | None = 2, @@ -331,28 +331,31 @@ def build_completion_query( :param q: the user autocomplete query :param taxonomy_names: a list of taxonomies we want to search in - :param lang: the language we want search in + :param langs: the languages we want search in :param size: number of results to return :param config: the index configuration to use :param fuzziness: fuzziness parameter for completion query :return: the built Query """ - - completion_clause = { - "field": f"synonyms.{lang}", - "size": size, - "contexts": {"taxonomy_name": taxonomy_names}, - } - - if fuzziness is not None: - completion_clause["fuzzy"] = {"fuzziness": fuzziness} - query = Search(index=config.taxonomy.index.name) - query = query.suggest( - "taxonomy_suggest", - q, - completion=completion_clause, - ) + # import pdb;pdb.set_trace(); + for lang in langs: + completion_clause = { + "field": f"synonyms.{lang}", + "size": size, + "contexts": {"taxonomy_name": taxonomy_names}, + "skip_duplicates": True, + } + if fuzziness is not None: + completion_clause["fuzzy"] = {"fuzziness": fuzziness} + + query = query.suggest( + f"taxonomy_suggest_{lang}", + q, + completion=completion_clause, + ) + # limit returned fields + query.source(fields=["id", "taxonomy_name", "name"]) return query diff --git a/app/taxonomy.py b/app/taxonomy.py index e1face38..5a67ce33 100644 --- a/app/taxonomy.py +++ b/app/taxonomy.py @@ -9,8 +9,9 @@ import cachetools import requests +from pydantic import BaseModel, ConfigDict -from app._types import JSONType +from app._types import FetcherStatus, JSONType from app.config import TaxonomyConfig, settings from app.utils import get_logger from app.utils.download import download_file, http_session, should_download_file @@ -22,7 +23,7 @@ logger = get_logger(__name__) -class TaxonomyNode: +class TaxonomyNode(BaseModel): """A taxonomy element. Each node has 0+ parents and 0+ children. Each node has the following @@ -38,25 +39,12 @@ class TaxonomyNode: for this language """ - __slots__ = ("id", "names", "parents", "children", "synonyms", "properties") - - def __init__( - self, - identifier: str, - names: Dict[str, str], - synonyms: Optional[Dict[str, List[str]]], - properties: Optional[Dict[str, Any]] = None, - ): - self.id: str = identifier - self.names: Dict[str, str] = names - self.parents: List["TaxonomyNode"] = [] - self.children: List["TaxonomyNode"] = [] - self.properties = properties or {} - - if synonyms: - self.synonyms = synonyms - else: - self.synonyms = {} + id: str + names: Dict[str, str] + parents: List["TaxonomyNode"] = [] + children: List["TaxonomyNode"] = [] + synonyms: Dict[str, List[str]] = {} + properties: Dict[str, Any] = {} def is_child_of(self, item: "TaxonomyNode") -> bool: """Return True if `item` is a child of `self` in the taxonomy.""" @@ -147,6 +135,11 @@ def __repr__(self): return "" % self.id +def purge_none_values(d: Dict[str, str | None]) -> Dict[str, str]: + """Remove None values from a dict.""" + return {k: v for k, v in d.items() if v is not None} + + class Taxonomy: """A class representing a taxonomy. @@ -157,8 +150,9 @@ class Taxonomy: node identifier to a `TaxonomyNode`. """ - def __init__(self) -> None: + def __init__(self, name: str) -> None: self.nodes: Dict[str, TaxonomyNode] = {} + self.name = name def add(self, key: str, node: TaxonomyNode) -> None: """Add a node to the taxonomy under the id `key`. @@ -263,20 +257,20 @@ def to_dict(self) -> JSONType: return export @classmethod - def from_dict(cls, data: JSONType) -> "Taxonomy": + def from_dict(cls, name: str, data: JSONType) -> "Taxonomy": """Create a Taxonomy from `data`. :param data: the taxonomy as a dict :return: a Taxonomy """ - taxonomy = Taxonomy() + taxonomy = Taxonomy(name) for key, key_data in data.items(): if key not in taxonomy: node = TaxonomyNode( - identifier=key, - names=key_data.get("name", {}), - synonyms=key_data.get("synonyms", None), + id=key, + names=purge_none_values(key_data.get("name", {})), + synonyms=key_data.get("synonyms", {}), properties={ k: v for k, v in key_data.items() @@ -293,17 +287,21 @@ def from_dict(cls, data: JSONType) -> "Taxonomy": return taxonomy @classmethod - def from_path(cls, file_path: Union[str, Path]) -> "Taxonomy": + def from_path(cls, name: str, file_path: Union[str, Path]) -> "Taxonomy": """Create a Taxonomy from a JSON file. :param file_path: a JSON file, gzipped (.json.gz) files are supported :return: a Taxonomy """ - return cls.from_dict(load_json(file_path)) # type: ignore + return cls.from_dict(name, load_json(file_path)) # type: ignore @classmethod def from_url( - cls, url: str, session: Optional[requests.Session] = None, timeout: int = 120 + cls, + name: str, + url: str, + session: Optional[requests.Session] = None, + timeout: int = 120, ) -> "Taxonomy": """Create a Taxonomy from a taxonomy file hosted at `url`. @@ -315,7 +313,7 @@ def from_url( session = http_session if session is None else session r = session.get(url, timeout=timeout) data = r.json() - return cls.from_dict(data) + return cls.from_dict(name, data) @cachetools.cached(cachetools.TTLCache(maxsize=100, ttl=3600)) @@ -345,7 +343,7 @@ def get_taxonomy( fpath = taxonomy_url[len("file://") :] if not fpath.startswith("/"): raise RuntimeError("Relative path (not yet) supported for taxonomy url") - return Taxonomy.from_path(fpath.rstrip("/")) + return Taxonomy.from_path(taxonomy_name, fpath.rstrip("/")) filename = f"{taxonomy_name}.json" cache_dir = DEFAULT_CACHE_DIR if cache_dir is None else cache_dir @@ -354,16 +352,26 @@ def get_taxonomy( if not should_download_file( taxonomy_url, taxonomy_path, force_download, download_newer ): - return Taxonomy.from_path(taxonomy_path) + return Taxonomy.from_path(taxonomy_name, taxonomy_path) cache_dir.mkdir(parents=True, exist_ok=True) logger.info("Downloading taxonomy, saving it in %s", taxonomy_path) download_file(taxonomy_url, taxonomy_path) - return Taxonomy.from_path(taxonomy_path) + return Taxonomy.from_path(taxonomy_name, taxonomy_path) -def iter_taxonomies(taxonomy_config: TaxonomyConfig) -> Iterator[tuple[str, Taxonomy]]: +def iter_taxonomies(taxonomy_config: TaxonomyConfig) -> Iterator[Taxonomy]: for taxonomy_source_config in taxonomy_config.sources: - yield taxonomy_source_config.name, get_taxonomy( - taxonomy_source_config.name, str(taxonomy_source_config.url) - ) + yield get_taxonomy(taxonomy_source_config.name, str(taxonomy_source_config.url)) + + +class TaxonomyNodeResult(BaseModel): + """Result for a taxonomy node transformation. + + This is used to eventually skip entry after preprocessing + """ + + model_config = ConfigDict(arbitrary_types_allowed=True) + + status: FetcherStatus + node: TaxonomyNode | None diff --git a/app/taxonomy_es.py b/app/taxonomy_es.py index a8f713e4..6d547129 100644 --- a/app/taxonomy_es.py +++ b/app/taxonomy_es.py @@ -23,18 +23,36 @@ def get_taxonomy_names( ) -> dict[tuple[str, str], dict[str, str]]: """Given a set of terms in different taxonomies, return their names""" filters = [] + no_lang_prefix_ids = {id_ for id_, _ in items if ":" not in id_} for id, taxonomy_name in items: + # we may not have lang prefix, in this case blind match them + id_term = ( + Q("term", id=id) + if id not in no_lang_prefix_ids + else Q("wildcard", id={"value": f"*:{id}"}) + ) # match one term - filters.append(Q("term", id=id) & Q("term", taxonomy_name=taxonomy_name)) + filters.append(id_term & Q("term", taxonomy_name=taxonomy_name)) query = ( Search(index=config.taxonomy.index.name) .filter("bool", should=filters, minimum_should_match=1) .params(size=len(filters)) ) - return { - (result.id, result.taxonomy_name): result.name.to_dict() - for result in query.execute().hits + results = query.execute().hits + # some id needs to be replaced by a value + no_lang_prefix = {result.id: result.id.split(":", 1)[-1] for result in results} + translations = { + (result.id, result.taxonomy_name): result.name.to_dict() for result in results } + # add values without prefix, because we may have some + translations.update( + { + (no_lang_prefix[result.id], result.taxonomy_name): result.name.to_dict() + for result in results + if no_lang_prefix[result.id] in no_lang_prefix_ids + } + ) + return translations def _normalize_synonym(token: str) -> str: @@ -94,10 +112,10 @@ def create_synonyms_files(taxonomy: Taxonomy, langs: list[str], target_dir: Path def create_synonyms(index_config: IndexConfig, target_dir: Path): - for name, taxonomy in iter_taxonomies(index_config.taxonomy): - target = target_dir / name + for taxonomy in iter_taxonomies(index_config.taxonomy): + target = target_dir / taxonomy.name # a temporary directory, we move at the end - target_tmp = target_dir / f"{name}.tmp" + target_tmp = target_dir / f"{taxonomy.name}.tmp" shutil.rmtree(target_tmp, ignore_errors=True) # ensure directory os.makedirs(target_tmp, mode=0o775, exist_ok=True) diff --git a/data/config/openfoodfacts.yml b/data/config/openfoodfacts.yml index a12d3b73..13d39535 100644 --- a/data/config/openfoodfacts.yml +++ b/data/config/openfoodfacts.yml @@ -160,6 +160,7 @@ indices: primary_color: "#341100" accent_color: "#ff8714" taxonomy: + preprocessor: app.openfoodfacts.TaxonomyPreprocessor sources: - name: categories url: https://static.openfoodfacts.org/data/taxonomies/categories.full.json diff --git a/docs/users/ref-web-components.md b/docs/users/ref-web-components.md index 4afe261e..a4f56fb4 100644 --- a/docs/users/ref-web-components.md +++ b/docs/users/ref-web-components.md @@ -39,6 +39,10 @@ You'll need it if you mix multiple search bars in the same page. +### searchalicious-taxonomy-suggest + + + ### searchalicious-facets diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0b14143a..615c4bc8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -42,12 +42,13 @@ } }, "node_modules/@75lb/deep-merge": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@75lb/deep-merge/-/deep-merge-1.1.1.tgz", - "integrity": "sha512-xvgv6pkMGBA6GwdyJbNAnDmfAIR/DfWhrj9jgWh3TY7gRm3KO46x/GPjRg6wJ0nOepwqrNxFfojebh0Df4h4Tw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@75lb/deep-merge/-/deep-merge-1.1.2.tgz", + "integrity": "sha512-08K9ou5VNbheZFxM5tDWoqjA3ImC50DiuuJ2tj1yEPRfkp8lLLg6XAaJ4On+a0yAXor/8ay5gHnAIshRM44Kpw==", "dev": true, + "license": "MIT", "dependencies": { - "lodash.assignwith": "^4.2.0", + "lodash": "^4.17.21", "typical": "^7.1.1" }, "engines": { @@ -3426,15 +3427,16 @@ } }, "node_modules/@web/test-runner-commands/node_modules/@web/dev-server-core": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-0.7.1.tgz", - "integrity": "sha512-alHd2j0f4e1ekqYDR8lWScrzR7D5gfsUZq3BP3De9bkFWM3AELINCmqqlVKmCtlkAdEc9VyQvNiEqrxraOdc2A==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-0.7.3.tgz", + "integrity": "sha512-GS+Ok6HiqNZOsw2oEv5V2OISZ2s/6icJodyGjUuD3RChr0G5HiESbKf2K8mZV4shTz9sRC9KSQf8qvno2gPKrQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/koa": "^2.11.6", "@types/ws": "^7.4.0", "@web/parse5-utils": "^2.1.0", - "chokidar": "^3.4.3", + "chokidar": "^4.0.1", "clone": "^2.1.2", "es-module-lexer": "^1.0.0", "get-stream": "^6.0.0", @@ -3448,7 +3450,7 @@ "mime-types": "^2.1.27", "parse5": "^6.0.1", "picomatch": "^2.2.2", - "ws": "^7.4.2" + "ws": "^7.5.10" }, "engines": { "node": ">=18.0.0" @@ -3459,6 +3461,7 @@ "resolved": "https://registry.npmjs.org/@web/parse5-utils/-/parse5-utils-2.1.0.tgz", "integrity": "sha512-GzfK5disEJ6wEjoPwx8AVNwUe9gYIiwc+x//QYxYDAFKUp4Xb1OJAGLc2l2gVrSQmtPGLKrTRcW90Hv4pEq1qA==", "dev": true, + "license": "MIT", "dependencies": { "@types/parse5": "^6.0.1", "parse5": "^6.0.1" @@ -3468,10 +3471,11 @@ } }, "node_modules/@web/test-runner-commands/node_modules/@web/test-runner-core": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/@web/test-runner-core/-/test-runner-core-0.13.1.tgz", - "integrity": "sha512-2hESALx/UFsAzK+ApWXAkFp2eCmwcs2yj1v4YjwV8F38sQumJ40P3px3BMjFwkOYDORtQOicW0RUeSw1g3BMLA==", + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/@web/test-runner-core/-/test-runner-core-0.13.4.tgz", + "integrity": "sha512-84E1025aUSjvZU1j17eCTwV7m5Zg3cZHErV3+CaJM9JPCesZwLraIa0ONIQ9w4KLgcDgJFw9UnJ0LbFf42h6tg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/code-frame": "^7.12.11", "@types/babel__code-frame": "^7.0.2", @@ -3481,15 +3485,15 @@ "@types/istanbul-lib-coverage": "^2.0.3", "@types/istanbul-reports": "^3.0.0", "@web/browser-logs": "^0.4.0", - "@web/dev-server-core": "^0.7.0", - "chokidar": "^3.4.3", + "@web/dev-server-core": "^0.7.3", + "chokidar": "^4.0.1", "cli-cursor": "^3.1.0", "co-body": "^6.1.0", "convert-source-map": "^2.0.0", "debounce": "^1.2.0", "dependency-graph": "^0.11.0", "globby": "^11.0.1", - "ip": "^2.0.1", + "internal-ip": "^6.2.0", "istanbul-lib-coverage": "^3.0.0", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.0.2", @@ -3504,27 +3508,53 @@ "node": ">=18.0.0" } }, - "node_modules/@web/test-runner-commands/node_modules/es-module-lexer": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.0.tgz", - "integrity": "sha512-pqrTKmwEIgafsYZAGw9kszYzmagcE/n4dbgwGWLEXg7J4QFJVQRBld8j3Q3GNez79jzxZshq0bcT962QHOghjw==", - "dev": true + "node_modules/@web/test-runner-commands/node_modules/chokidar": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", + "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } }, - "node_modules/@web/test-runner-commands/node_modules/ip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz", - "integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==", - "dev": true + "node_modules/@web/test-runner-commands/node_modules/es-module-lexer": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz", + "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==", + "dev": true, + "license": "MIT" }, "node_modules/@web/test-runner-commands/node_modules/lru-cache": { "version": "8.0.5", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-8.0.5.tgz", "integrity": "sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==", "dev": true, + "license": "ISC", "engines": { "node": ">=16.14" } }, + "node_modules/@web/test-runner-commands/node_modules/readdirp": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", + "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@web/test-runner-core": { "version": "0.10.29", "resolved": "https://registry.npmjs.org/@web/test-runner-core/-/test-runner-core-0.10.29.tgz", @@ -3905,12 +3935,13 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, + "license": "MIT", "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -4724,6 +4755,19 @@ "node": ">=0.10.0" } }, + "node_modules/default-gateway": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz", + "integrity": "sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "execa": "^5.0.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -5303,6 +5347,37 @@ "node": ">= 0.6" } }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -5412,10 +5487,11 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, + "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" }, @@ -5894,6 +5970,16 @@ "node": ">= 6" } }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -5985,6 +6071,25 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, + "node_modules/internal-ip": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/internal-ip/-/internal-ip-6.2.0.tgz", + "integrity": "sha512-D8WGsR6yDt8uq7vDMu7mjcR+yRMm3dW8yufyChmszWRjcSHuxLBkR3GdS2HZAjodsaGuCvXeEJpueisXJULghg==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-gateway": "^6.0.0", + "ipaddr.js": "^1.9.1", + "is-ip": "^3.1.0", + "p-event": "^4.2.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/internal-ip?sponsor=1" + } + }, "node_modules/intersection-observer": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/intersection-observer/-/intersection-observer-0.12.2.tgz", @@ -5997,6 +6102,26 @@ "integrity": "sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ==", "dev": true }, + "node_modules/ip-regex": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-4.3.0.tgz", + "integrity": "sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -6096,6 +6221,19 @@ "node": ">=0.10.0" } }, + "node_modules/is-ip": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-ip/-/is-ip-3.1.0.tgz", + "integrity": "sha512-35vd5necO7IitFPjd/YBeqwWnyDWbuLH9ZXQdMfDA8TEo7pv5X8yfrvVO3xbJbLUlERCMvf6X0hTUamQxCYJ9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-regex": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-module": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", @@ -6107,6 +6245,7 @@ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.12.0" } @@ -6810,12 +6949,6 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, - "node_modules/lodash.assignwith": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.assignwith/-/lodash.assignwith-4.2.0.tgz", - "integrity": "sha512-ZznplvbvtjK2gMvnQ1BR/zqPFZmS6jbK4p+6Up4xcRYA7yMIwxHCfbTcrYxXKzzqLsQ05eJPVznEW3tuwV7k1g==", - "dev": true - }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", @@ -6955,12 +7088,13 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, + "license": "MIT", "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -7169,6 +7303,19 @@ "node": ">=0.10.0" } }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/object-inspect": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", @@ -7254,6 +7401,32 @@ "node": ">= 0.8.0" } }, + "node_modules/p-event": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/p-event/-/p-event-4.2.0.tgz", + "integrity": "sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-timeout": "^3.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -7284,6 +7457,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", @@ -7985,10 +8171,11 @@ } }, "node_modules/rollup": { - "version": "2.79.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz", - "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==", + "version": "2.79.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", + "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "dev": true, + "license": "MIT", "bin": { "rollup": "dist/bin/rollup" }, @@ -8382,6 +8569,16 @@ "node": ">=8" } }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -8565,6 +8762,7 @@ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, + "license": "MIT", "dependencies": { "is-number": "^7.0.0" }, @@ -9142,10 +9340,11 @@ "dev": true }, "node_modules/ws": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", - "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8.3.0" }, diff --git a/frontend/public/off.html b/frontend/public/off.html index cda33377..895209fc 100644 --- a/frontend/public/off.html +++ b/frontend/public/off.html @@ -286,7 +286,9 @@
  • - + + +
  • diff --git a/frontend/src/event-listener-setup.ts b/frontend/src/event-listener-setup.ts index 5a160f4d..24923844 100644 --- a/frontend/src/event-listener-setup.ts +++ b/frontend/src/event-listener-setup.ts @@ -2,30 +2,8 @@ * Event registration mixin to take into account AnimationFrame and avoid race conditions on register / unregister events */ //import {LitElement} from 'lit'; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type Constructor = new (...args: any[]) => T; - -export interface EventRegistrationInterface { - /** - * Calls window.addEventListener if not remove before next AnimationFrame - * @param event - event name - * @param handler - function handling event. Beware of using () => this.method to have method bind to this. - */ - addEventHandler( - event: string, - handler: EventListenerOrEventListenerObject - ): void; - /** - * Removes window.removeEventListener but only if really needed - * @param event - event name - * @param handler - function handling event. - */ - removeEventHandler( - event: string, - handler: EventListenerOrEventListenerObject - ): void; -} +import {Constructor} from './mixins/utils'; +import {EventRegistrationInterface} from './interfaces/events-interfaces'; export const EventRegistrationMixin = >( superClass: T diff --git a/frontend/src/interfaces/chart-interfaces.ts b/frontend/src/interfaces/chart-interfaces.ts new file mode 100644 index 00000000..c5aa847d --- /dev/null +++ b/frontend/src/interfaces/chart-interfaces.ts @@ -0,0 +1,8 @@ +interface ChartSearchParamPOST { + chart_type: string; + field?: string; + x?: string; + y?: string; +} + +export type ChartSearchParam = ChartSearchParamPOST | string; diff --git a/frontend/src/interfaces/events-interfaces.ts b/frontend/src/interfaces/events-interfaces.ts new file mode 100644 index 00000000..043a30c7 --- /dev/null +++ b/frontend/src/interfaces/events-interfaces.ts @@ -0,0 +1,20 @@ +export interface EventRegistrationInterface { + /** + * Calls window.addEventListener if not remove before next AnimationFrame + * @param event - event name + * @param handler - function handling event. Beware of using () => this.method to have method bind to this. + */ + addEventHandler( + event: string, + handler: EventListenerOrEventListenerObject + ): void; + /** + * Removes window.removeEventListener but only if really needed + * @param event - event name + * @param handler - function handling event. + */ + removeEventHandler( + event: string, + handler: EventListenerOrEventListenerObject + ): void; +} diff --git a/frontend/src/interfaces/facets-interfaces.ts b/frontend/src/interfaces/facets-interfaces.ts new file mode 100644 index 00000000..96624120 --- /dev/null +++ b/frontend/src/interfaces/facets-interfaces.ts @@ -0,0 +1,6 @@ +import {LitElement} from 'lit'; + +export interface SearchaliciousFacetsInterface extends LitElement { + setSelectedTermsByFacet(value: Record): void; + selectTermByTaxonomy(taxonomy: string, term: string): boolean; +} diff --git a/frontend/src/interfaces/history-interfaces.ts b/frontend/src/interfaces/history-interfaces.ts new file mode 100644 index 00000000..25c29db1 --- /dev/null +++ b/frontend/src/interfaces/history-interfaces.ts @@ -0,0 +1,43 @@ +import {SearchaliciousFacetsInterface} from './facets-interfaces'; +import {SearchaliciousSortInterface} from './sort-interfaces'; +import {SearchParameters} from './search-params-interfaces'; + +/** + * A set of values that can be deduced from parameters, + * and are easy to use to set search components to corresponding values + */ +export type HistoryOutput = { + query?: string; + page?: number; + sortOptionId?: string; + selectedTermsByFacet?: Record; + history: HistoryParams; +}; + +// type of object containing search parameters +export type HistoryParams = { + [key in HistorySearchParams]?: string; +}; + +/** + * Parameters we need to put in URL to be able to deep link the search + */ +export enum HistorySearchParams { + QUERY = 'q', + FACETS_FILTERS = 'facetsFilters', + PAGE = 'page', + SORT_BY = 'sort_by', +} + +export type SearchaliciousHistoryInterface = { + query: string; + name: string; + _currentPage?: number; + relatedFacets: () => SearchaliciousFacetsInterface[]; + _facetsFilters: () => string; + _sortElement: () => SearchaliciousSortInterface | null; + convertHistoryParamsToValues: (params: URLSearchParams) => HistoryOutput; + setValuesFromHistory: (values: HistoryOutput) => void; + buildHistoryParams: (params: SearchParameters) => HistoryParams; + setParamFromUrl: () => {launchSearch: boolean; values: HistoryOutput}; +}; diff --git a/frontend/src/interfaces/search-bar-interfaces.ts b/frontend/src/interfaces/search-bar-interfaces.ts new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/interfaces/search-ctl-interfaces.ts b/frontend/src/interfaces/search-ctl-interfaces.ts new file mode 100644 index 00000000..2beb61dc --- /dev/null +++ b/frontend/src/interfaces/search-ctl-interfaces.ts @@ -0,0 +1,34 @@ +import {EventRegistrationInterface} from './events-interfaces'; +import {SearchaliciousHistoryInterface} from '../interfaces/history-interfaces'; +import {SearchaliciousFacetsInterface} from './facets-interfaces'; + +export interface SearchaliciousSearchInterface + extends EventRegistrationInterface, + SearchaliciousHistoryInterface { + query: string; + name: string; + baseUrl: string; + langs: string; + index: string; + pageSize: number; + lastQuery?: string; + lastFacetsFilters?: string; + isQueryChanged: boolean; + isFacetsChanged: boolean; + isSearchChanged: boolean; + canReset: boolean; + + updateSearchSignals(): void; + search(): Promise; + relatedFacets(): SearchaliciousFacetsInterface[]; + relatedFacets(): SearchaliciousFacetsInterface[]; + _facetsFilters(): string; + resetFacets(launchSearch?: boolean): void; +} + +/** + * An interface to be able to get the search controller corresponding to a component + */ +export interface SearchCtlGetInterface { + getSearchCtl(): SearchaliciousSearchInterface; +} diff --git a/frontend/src/interfaces/search-params-interfaces.ts b/frontend/src/interfaces/search-params-interfaces.ts new file mode 100644 index 00000000..f4c56064 --- /dev/null +++ b/frontend/src/interfaces/search-params-interfaces.ts @@ -0,0 +1,14 @@ +import {SortParameters} from './sort-interfaces'; +import {ChartSearchParam} from './chart-interfaces'; + +export interface SearchParameters extends SortParameters { + q: string; + boost_phrase: Boolean; + langs: string[]; + page_size: string; + page?: string; + index_id?: string; + facets?: string[]; + params?: string[]; + charts?: string | ChartSearchParam[]; +} diff --git a/frontend/src/interfaces/sort-interfaces.ts b/frontend/src/interfaces/sort-interfaces.ts new file mode 100644 index 00000000..47bc6e05 --- /dev/null +++ b/frontend/src/interfaces/sort-interfaces.ts @@ -0,0 +1,10 @@ +export interface SortParameters { + sort_by?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sort_params?: Record; +} + +export interface SearchaliciousSortInterface { + getSortParameters(): SortParameters | null; + setSortOptionById(optionId: string | undefined): void; +} diff --git a/frontend/src/interfaces/suggestion-interfaces.ts b/frontend/src/interfaces/suggestion-interfaces.ts new file mode 100644 index 00000000..daaef3fe --- /dev/null +++ b/frontend/src/interfaces/suggestion-interfaces.ts @@ -0,0 +1,43 @@ +import {TemplateResult} from 'lit'; + +/** + * Type for suggestion option. + */ +export type SuggestionSelectionOption = { + /** + * value assigned to this suggestion + */ + value: string; + /** + * Label to display this suggestion + */ + label: string; + /** + * Unique id for this suggestion + * + * It is important when we have multiple suggestions sources + */ + id: string; + /** + * text that gave the suggestion + * + * It is important because we might do a suggestion on part of the searched terms + */ + input: string; +}; + +/** + * Type for a suggested option + */ +export type SuggestOption = SuggestionSelectionOption & { + /** + * source of this suggestion + */ + source: SearchaliciousSuggesterInterface; +}; + +export interface SearchaliciousSuggesterInterface { + getSuggestions(_value: string): Promise; + selectSuggestion(_selected: SuggestionSelectionOption): void; + renderSuggestion(_suggestion: SuggestOption, _index: number): TemplateResult; +} diff --git a/frontend/src/mixins/history.ts b/frontend/src/mixins/history.ts index 7df15b79..3daa4613 100644 --- a/frontend/src/mixins/history.ts +++ b/frontend/src/mixins/history.ts @@ -5,55 +5,23 @@ import { removeParenthesis, } from '../utils/url'; import {isNullOrUndefined} from '../utils'; -import {SearchParameters} from './search-ctl'; +import {SearchParameters} from '../interfaces/search-params-interfaces'; import {property} from 'lit/decorators.js'; import {QueryOperator} from '../utils/enums'; +import { + HistoryOutput, + HistoryParams, + HistorySearchParams, + SearchaliciousHistoryInterface, +} from '../interfaces/history-interfaces'; import {SearchaliciousSort} from '../search-sort'; import {SearchaliciousFacets} from '../search-facets'; import {Constructor} from './utils'; import {DEFAULT_SEARCH_NAME} from '../utils/constants'; -export type SearchaliciousHistoryInterface = { - query: string; - name: string; - _currentPage?: number; - _facetsNodes: () => SearchaliciousFacets[]; - _facetsFilters: () => string; - _sortElement: () => SearchaliciousSort | null; - convertHistoryParamsToValues: (params: URLSearchParams) => HistoryOutput; - setValuesFromHistory: (values: HistoryOutput) => void; - buildHistoryParams: (params: SearchParameters) => HistoryParams; - setParamFromUrl: () => {launchSearch: boolean; values: HistoryOutput}; -}; - -/** - * A set of values that can be deduced from parameters, - * and are easy to use to set search components to corresponding values - */ -export type HistoryOutput = { - query?: string; - page?: number; - sortOptionId?: string; - selectedTermsByFacet?: Record; - history: HistoryParams; -}; -/** - * Parameters we need to put in URL to be able to deep link the search - */ -export enum HistorySearchParams { - QUERY = 'q', - FACETS_FILTERS = 'facetsFilters', - PAGE = 'page', - SORT_BY = 'sort_by', -} - // name of search params as an array (to ease iteration) export const SEARCH_PARAMS = Object.values(HistorySearchParams); -// type of object containing search parameters -export type HistoryParams = { - [key in HistorySearchParams]?: string; -}; /** * Object to convert the URL params to the original values * @@ -85,17 +53,27 @@ const HISTORY_VALUES: Record< sortOptionId: history.sort_by, }; }, + // NOTE: this approach is a bit brittle, + // shan't we instead store selected facets in the URL directly? [HistorySearchParams.FACETS_FILTERS]: (history) => { if (!history.facetsFilters) { return {}; } // we split back the facetsFilters expression to its sub components // parameter value is facet1:(value1 OR value2) AND facet2:(value3 OR value4) + // but beware quotes: facet1:"en:value1" const selectedTermsByFacet = history.facetsFilters .split(QueryOperator.AND) .reduce((acc, filter) => { - const [key, value] = filter.split(':'); - acc[key] = removeParenthesis(value).split(QueryOperator.OR); + const [key, ...values] = filter.split(':'); + // keep last parts + let value = values.join(':'); + // remove parenthesis if any + value = value.replace(/^\((.*)\)$/, '$1'); + acc[key] = acc[key] || []; + value.split(QueryOperator.OR).forEach((value) => { + acc[key].push(removeParenthesis(value)); + }); return acc; }, {} as Record); @@ -107,7 +85,7 @@ const HISTORY_VALUES: Record< /** * Mixin to handle the history of the search * It exists to have the logic of the history in a single place - * It has to be inherited by SearchaliciousSearchMixin to implement _facetsNodes and _facetsFilters functionss + * It has to be inherited by SearchaliciousSearchMixin to implement relatedFacets and _facetsFilters functionss * @param superClass * @constructor */ @@ -125,7 +103,7 @@ export const SearchaliciousHistoryMixin = >( _sortElement = (): SearchaliciousSort | null => { throw new Error('Method not implemented.'); }; - _facetsNodes = (): SearchaliciousFacets[] => { + relatedFacets = (): SearchaliciousFacets[] => { throw new Error('Method not implemented.'); }; _facetsFilters = (): string => { @@ -161,7 +139,7 @@ export const SearchaliciousHistoryMixin = >( this._sortElement()?.setSortOptionById(values.sortOptionId); // set facets terms using linked facets nodes if (values.selectedTermsByFacet) { - this._facetsNodes().forEach((facets) => + this.relatedFacets().forEach((facets) => facets.setSelectedTermsByFacet(values.selectedTermsByFacet!) ); } diff --git a/frontend/src/mixins/search-ctl-getter.ts b/frontend/src/mixins/search-ctl-getter.ts new file mode 100644 index 00000000..196c2dd3 --- /dev/null +++ b/frontend/src/mixins/search-ctl-getter.ts @@ -0,0 +1,43 @@ +import {Constructor} from './utils'; +import { + SearchaliciousSearchInterface, + SearchCtlGetInterface, +} from '../interfaces/search-ctl-interfaces'; + +/** + * Some component may want to refer to the corresponding search controller instance + * + * This mixin provides a getter to get the corresponding search controller instance + */ +export const SearchCtlGetMixin = >( + superClass: T +) => { + class SearchCtlGetMixinClass extends superClass { + get searchName(): string { + throw Error('searchName attribute must be implemented in base class'); + } + + _searchCtl_cache: SearchaliciousSearchInterface | undefined; + + /** get corresponding search bar instance */ + getSearchCtl(): SearchaliciousSearchInterface { + if (!this._searchCtl_cache) { + let searchCtl: SearchaliciousSearchInterface | undefined = undefined; + document.querySelectorAll(`searchalicious-bar`).forEach((item) => { + const candidate = item as SearchaliciousSearchInterface; + if (candidate.name === this.searchName) { + searchCtl = candidate; + } + }); + if (searchCtl == null) { + throw new Error(`No search bar found for ${this.searchName}`); + } + // optimize + this._searchCtl_cache = searchCtl; + } + return this._searchCtl_cache; + } + } + + return SearchCtlGetMixinClass as Constructor & T; +}; diff --git a/frontend/src/mixins/search-ctl.ts b/frontend/src/mixins/search-ctl.ts index 437bc3ea..60593530 100644 --- a/frontend/src/mixins/search-ctl.ts +++ b/frontend/src/mixins/search-ctl.ts @@ -1,13 +1,10 @@ import {LitElement} from 'lit'; import {property, state} from 'lit/decorators.js'; -import { - EventRegistrationInterface, - EventRegistrationMixin, -} from '../event-listener-setup'; +import {EventRegistrationMixin} from '../event-listener-setup'; import {QueryOperator, SearchaliciousEvents} from '../utils/enums'; import {ChangePageEvent} from '../events'; import {Constructor} from './utils'; -import {SearchaliciousSort, SortParameters} from '../search-sort'; +import {SearchaliciousSort} from '../search-sort'; import {SearchaliciousFacets} from '../search-facets'; import {setCurrentURLHistory} from '../utils/url'; import {isNullOrUndefined} from '../utils'; @@ -16,15 +13,14 @@ import { DEFAULT_SEARCH_NAME, PROPERTY_LIST_DIVIDER, } from '../utils/constants'; -import { - HistorySearchParams, - SearchaliciousHistoryInterface, - SearchaliciousHistoryMixin, -} from './history'; +import {SearchaliciousSearchInterface} from '../interfaces/search-ctl-interfaces'; +import {HistorySearchParams} from '../interfaces/history-interfaces'; +import {SearchParameters} from '../interfaces/search-params-interfaces'; +import {ChartSearchParam} from '../interfaces/chart-interfaces'; +import {SearchaliciousHistoryMixin} from './history'; import { SearchaliciousDistributionChart, SearchaliciousScatterChart, - ChartSearchParam, } from '../search-chart'; import { canResetSearch, @@ -35,41 +31,6 @@ import { import {SignalWatcher} from '@lit-labs/preact-signals'; import {isTheSameSearchName} from '../utils/search'; -export interface SearchParameters extends SortParameters { - q: string; - boost_phrase: Boolean; - langs: string[]; - page_size: string; - page?: string; - index_id?: string; - facets?: string[]; - params?: string[]; - charts?: string | ChartSearchParam[]; -} -export interface SearchaliciousSearchInterface - extends EventRegistrationInterface, - SearchaliciousHistoryInterface { - query: string; - name: string; - baseUrl: string; - langs: string; - index: string; - pageSize: number; - lastQuery?: string; - lastFacetsFilters?: string; - isQueryChanged: boolean; - isFacetsChanged: boolean; - isSearchChanged: boolean; - canReset: boolean; - - updateSearchSignals(): void; - search(): Promise; - _facetsNodes(): SearchaliciousFacets[]; - _facetsFilters(): string; - resetFacets(launchSearch?: boolean): void; - selectTermByTaxonomy(taxonomy: string, term: string): void; -} - export const SearchaliciousSearchMixin = >( superClass: T ) => { @@ -201,30 +162,6 @@ export const SearchaliciousSearchMixin = >( isSearchChanged(this.name).value = this.isSearchChanged; } - /** list of facets containers */ - _facetsParentNode() { - return document.querySelectorAll( - `searchalicious-facets[search-name=${this.name}]` - ); - } - - /** - * Select a term by taxonomy in all facets - * It will update the selected terms in facets - * @param taxonomy - * @param term - */ - selectTermByTaxonomy(taxonomy: string, term: string) { - for (const facets of this._facetsParentNode()) { - // if true, the facets has been updated - if ( - (facets as SearchaliciousFacets).selectTermByTaxonomy(taxonomy, term) - ) { - return; - } - } - } - /** * @returns the sort element linked to this search ctl */ @@ -277,10 +214,11 @@ export const SearchaliciousSearchMixin = >( /** * @returns all searchalicious-facets elements linked to this search ctl */ - override _facetsNodes = (): SearchaliciousFacets[] => { + override relatedFacets = (): SearchaliciousFacets[] => { const allNodes: SearchaliciousFacets[] = []; - // search facets elements, we can't filter on search-name because of default value… - this._facetsParentNode()?.forEach((item) => { + // search facets elements, + // we can't directly filter on search-name in selector because of default value + document.querySelectorAll(`searchalicious-facets`).forEach((item) => { const facetElement = item as SearchaliciousFacets; if (facetElement.searchName == this.name) { allNodes.push(facetElement); @@ -292,8 +230,8 @@ export const SearchaliciousSearchMixin = >( /** * Get the list of facets we want to request */ - _facets(): string[] { - const names = this._facetsNodes() + _facetsNames(): string[] { + const names = this.relatedFacets() .map((facets) => facets.getFacetsNames()) .flat(); return [...new Set(names)]; @@ -337,14 +275,14 @@ export const SearchaliciousSearchMixin = >( * @returns an expression to be added to query */ override _facetsFilters = (): string => { - const allFilters: string[] = this._facetsNodes() + const allFilters: string[] = this.relatedFacets() .map((facets) => facets.getSearchFilters()) .flat(); return allFilters.join(QueryOperator.AND); }; resetFacets(launchSearch = true) { - this._facetsNodes().forEach((facets) => facets.reset(launchSearch)); + this.relatedFacets().forEach((facets) => facets.reset(launchSearch)); } /* @@ -506,8 +444,8 @@ export const SearchaliciousSearchMixin = >( params.page = page.toString(); } // facets - if (this._facets().length > 0) { - params.facets = this._facets(); + if (this._facetsNames().length > 0) { + params.facets = this._facetsNames(); } const charts = this._chartParams(!needsPOST); diff --git a/frontend/src/mixins/search-results-ctl.ts b/frontend/src/mixins/search-results-ctl.ts index 1deba718..735d5f18 100644 --- a/frontend/src/mixins/search-results-ctl.ts +++ b/frontend/src/mixins/search-results-ctl.ts @@ -5,10 +5,8 @@ import {Constructor} from './utils'; import {DEFAULT_SEARCH_NAME} from '../utils/constants'; import {SearchResultDetail, searchResultDetail} from '../signals'; import {Signal, SignalWatcher} from '@lit-labs/preact-signals'; -import { - EventRegistrationInterface, - EventRegistrationMixin, -} from '../event-listener-setup'; +import {EventRegistrationInterface} from '../interfaces/events-interfaces'; +import {EventRegistrationMixin} from '../event-listener-setup'; export interface SearchaliciousResultsCtlInterface extends EventRegistrationInterface { diff --git a/frontend/src/mixins/suggestion-selection.ts b/frontend/src/mixins/suggestion-selection.ts index a7c81095..b66f407a 100644 --- a/frontend/src/mixins/suggestion-selection.ts +++ b/frontend/src/mixins/suggestion-selection.ts @@ -1,7 +1,8 @@ import {LitElement} from 'lit'; import {Constructor} from './utils'; -import {property} from 'lit/decorators.js'; +import {property, state} from 'lit/decorators.js'; import {DebounceMixin, DebounceMixinInterface} from './debounce'; +import {SuggestionSelectionOption} from '../interfaces/suggestion-interfaces'; /** * Interface for the Suggestion Selection mixin. @@ -10,39 +11,33 @@ export interface SuggestionSelectionMixinInterface extends DebounceMixinInterface { inputName: string; options: SuggestionSelectionOption[]; - value: string; - currentIndex: number; - getOptionIndex: number; + selectedOption: SuggestionSelectionOption | undefined; + inputValue: string; visible: boolean; isLoading: boolean; - currentOption: SuggestionSelectionOption | undefined; + currentIndex: number; onInput(event: InputEvent): void; handleInput(value: string): void; blurInput(): void; - resetInput(): void; - submit(isSuggestion?: boolean): void; + resetInput(selectedOption?: SuggestionSelectionOption): void; + submitSuggestion(isSuggestion?: boolean): void; handleArrowKey(direction: 'up' | 'down'): void; handleEnter(event: KeyboardEvent): void; handleEscape(): void; onKeyDown(event: KeyboardEvent): void; - onClick(index: number): () => void; + onClick(option: SuggestionSelectionOption): () => void; onFocus(): void; onBlur(): void; } -/** - * Type for suggestion option. - */ -export type SuggestionSelectionOption = { - value: string; - label: string; -}; + /** * Type for suggestion result. */ export type SuggestionSelectionResult = { value: string; label?: string; + id: string; }; /** @@ -65,51 +60,59 @@ export const SuggestionSelectionMixin = >( @property({attribute: false, type: Array}) options: SuggestionSelectionOption[] = []; - // selected values - @property() - value = ''; - - @property({attribute: false}) + /** + * index of suggestion about to be selected + * + * It mainly tracks current focused suggestion + * when navigating with arrow keys + */ + @state() currentIndex = 0; + // track current input value + @state() + inputValue = ''; + + /** + * The option that was selected + * + * Note that it might be from outside the options list (for specific inputs) + */ + selectedOption: SuggestionSelectionOption | undefined; + @property({attribute: false}) visible = false; @property({attribute: false}) isLoading = false; - /** - * This method is used to get the current index. - * It remove the offset of 1 because the currentIndex is 1-based. - * @returns {number} The current index. - */ - get getOptionIndex() { - return this.currentIndex - 1; - } - - get currentOption() { - return this.options[this.getOptionIndex]; - } - getInput() { return this.shadowRoot!.querySelector('input'); } /** - * Handles the input event on the suggestion and dispatch custom event : "suggestion-input". + * Handles the input event on the suggestion, that is when the option is suggested + * and dispatch to handleInput * @param {InputEvent} event - The input event. */ onInput(event: InputEvent) { const value = (event.target as HTMLInputElement).value; - this.value = value; + this.inputValue = value; this.handleInput(value); } + /** + * React on changing input + * + * This method is to be implemented by the component to ask for new suggestions based on the input value. + * @param value the current input value + */ handleInput(value: string) { throw new Error( `handleInput method must be implemented for ${this} with ${value}` ); } + /** * This method is used to remove focus from the input element. * It is used to quit after selecting an option. @@ -125,14 +128,18 @@ export const SuggestionSelectionMixin = >( * This method is used to reset the input value and blur it. * It is used to reset the input after a search. */ - resetInput() { - this.value = ''; + resetInput(selectedOption?: SuggestionSelectionOption) { this.currentIndex = 0; const input = this.getInput(); if (!input) { return; } - input.value = ''; + // remove part of the text that generates the selection + if (selectedOption) { + input.value = input.value.replace(selectedOption?.input || '', ''); + } else { + input.value = ''; + } this.blurInput(); } @@ -141,7 +148,7 @@ export const SuggestionSelectionMixin = >( * It is used to submit the input value after selecting an option. * @param {boolean} isSuggestion - A boolean value to check if the value is a suggestion. */ - submit(isSuggestion = false) { + submitSuggestion(isSuggestion = false) { throw new Error( `submit method must be implemented for ${this} with ${isSuggestion}` ); @@ -168,12 +175,18 @@ export const SuggestionSelectionMixin = >( let isSuggestion = false; if (this.currentIndex) { isSuggestion = true; - this.value = this.currentOption!.value; + this.selectedOption = this.options[this.currentIndex - 1]; } else { + // direct suggestion const value = (event.target as HTMLInputElement).value; - this.value = value; + this.selectedOption = { + value: value, + label: value, + id: '--direct-suggestion--' + value, + input: value, + }; } - this.submit(isSuggestion); + this.submitSuggestion(isSuggestion); } /** @@ -206,14 +219,13 @@ export const SuggestionSelectionMixin = >( /** * On a click on the suggestion option, we select it as value and submit it. - * @param index + * @param option - chosen option */ - onClick(index: number) { + onClick(option: SuggestionSelectionOption) { return () => { - this.value = this.options[index].value; - // we need to increment the index because currentIndex is 1-based - this.currentIndex = index + 1; - this.submit(true); + this.selectedOption = option; + this.currentIndex = 0; + this.submitSuggestion(true); }; } diff --git a/frontend/src/mixins/suggestions-ctl.ts b/frontend/src/mixins/suggestions-ctl.ts index e74079e6..46c42db7 100644 --- a/frontend/src/mixins/suggestions-ctl.ts +++ b/frontend/src/mixins/suggestions-ctl.ts @@ -9,6 +9,8 @@ import {VersioningMixin, VersioningMixinInterface} from './versioning'; export type TermOption = { id: string; text: string; + input: string; + name: string; taxonomy_name: string; }; @@ -29,6 +31,8 @@ export interface SearchaliciousTaxonomiesInterface taxonomiesBaseUrl: string; langs: string; + termLabel(term: TermOption): string; + /** * Method to get taxonomies terms. * @param {string} q - The query string. @@ -61,8 +65,21 @@ export const SearchaliciousTermsMixin = >( @property({attribute: 'base-url'}) taxonomiesBaseUrl = '/'; - @property() - langs = 'en'; + /** + * langs to get suggestion from. + * + * Must be implementetd in child class + */ + get langs(): string { + throw new Error('langs must be defined in child class'); + } + + termLabel(term: TermOption): string { + return ( + term.text + + (term.name && term.name != term.text ? ` (${term.name})` : '') + ); + } /** * build URL to search taxonomies terms from input @@ -72,7 +89,7 @@ export const SearchaliciousTermsMixin = >( */ _termsUrl(q: string, taxonomyNames: string[]) { const baseUrl = this.taxonomiesBaseUrl.replace(/\/+$/, ''); - return `${baseUrl}/autocomplete?q=${q}&lang=${this.langs}&taxonomy_names=${taxonomyNames}&size=5`; + return `${baseUrl}/autocomplete?q=${q}&langs=${this.langs}&taxonomy_names=${taxonomyNames}&size=5`; } /** @@ -85,6 +102,9 @@ export const SearchaliciousTermsMixin = >( q: string, taxonomyNames: string[] ): Promise { + if (!q) { + return Promise.resolve({options: []}); + } this.isTermsLoading = true; // get the version of the terms for each taxonomy const version = this.incrementVersion(); diff --git a/frontend/src/search-a-licious.ts b/frontend/src/search-a-licious.ts index ac6d0fa8..3d21d500 100644 --- a/frontend/src/search-a-licious.ts +++ b/frontend/src/search-a-licious.ts @@ -4,6 +4,7 @@ export {SearchaliciousCheckbox} from './search-checkbox'; export {SearchaliciousRadio} from './search-radio'; export {SearchaliciousToggle} from './search-toggle'; export {SearchaliciousBar} from './search-bar'; +export {SearchaliciousTaxonomySuggester} from './search-suggester'; export {SearchaliciousButton} from './search-button'; export {SearchaliciousPages} from './search-pages'; export {SearchaliciousFacets} from './search-facets'; diff --git a/frontend/src/search-autocomplete.ts b/frontend/src/search-autocomplete.ts index f380baba..1143e2eb 100644 --- a/frontend/src/search-autocomplete.ts +++ b/frontend/src/search-autocomplete.ts @@ -87,7 +87,7 @@ export class SearchaliciousAutocomplete extends SuggestionSelectionMixin( ? this.options.map( (option, index) => html`
  • ${option.label}
  • ` @@ -118,23 +118,25 @@ export class SearchaliciousAutocomplete extends SuggestionSelectionMixin( * It is used to submit the input value after selecting an option. * @param {boolean} isSuggestion - A boolean value to check if the value is a suggestion or a free input from the user. */ - override submit(isSuggestion = false) { - if (!this.value) return; + override submitSuggestion(isSuggestion = false) { + const selectedOption = this.selectedOption; + if (!selectedOption) return; const inputEvent = new CustomEvent( SearchaliciousEvents.AUTOCOMPLETE_SUBMIT, { // we send both value and label detail: { - value: this.value, - label: isSuggestion ? this.currentOption!.label : undefined, + value: selectedOption.value, + label: isSuggestion ? selectedOption!.label : undefined, } as SuggestionSelectionResult, bubbles: true, composed: true, } ); this.dispatchEvent(inputEvent); - this.resetInput(); + this.resetInput(selectedOption); + this.selectedOption = undefined; } /** @@ -148,14 +150,18 @@ export class SearchaliciousAutocomplete extends SuggestionSelectionMixin( type="text" name="${this.inputName}" id="${this.inputName}" - .value=${this.value} + .value=${this.selectedOption?.value || ''} @input=${this.onInput} @keydown=${this.onKeyDown} autocomplete="off" @focus=${this.onFocus} @blur=${this.onBlur} /> -
      +
        ${this.isLoading ? html`
      • ${msg('Loading...')}
      • ` : this._renderSuggestions()} diff --git a/frontend/src/search-bar.ts b/frontend/src/search-bar.ts index c5321094..a02d9704 100644 --- a/frontend/src/search-bar.ts +++ b/frontend/src/search-bar.ts @@ -1,12 +1,14 @@ -import {LitElement, html, css} from 'lit'; -import {customElement, property} from 'lit/decorators.js'; +import {LitElement, html, css, PropertyValues} from 'lit'; +import {customElement, property, state} from 'lit/decorators.js'; import {SearchaliciousSearchMixin} from './mixins/search-ctl'; import {localized, msg} from '@lit/localize'; import {setLocale} from './localization/main'; -import {SearchaliciousTermsMixin} from './mixins/suggestions-ctl'; import {SuggestionSelectionMixin} from './mixins/suggestion-selection'; +import {SuggestOption} from './interfaces/suggestion-interfaces'; +import { + SearchaliciousSuggester /*SuggesterRegistry*/, +} from './search-suggester'; import {classMap} from 'lit/directives/class-map.js'; -import {removeLangFromTermId} from './utils/taxonomies'; import {searchBarInputAndButtonStyle} from './css/header'; import {SearchaliciousEvents} from './utils/enums'; import {isTheSameSearchName} from './utils/search'; @@ -20,7 +22,7 @@ import {isTheSameSearchName} from './utils/search'; @customElement('searchalicious-bar') @localized() export class SearchaliciousBar extends SuggestionSelectionMixin( - SearchaliciousTermsMixin(SearchaliciousSearchMixin(LitElement)) + SearchaliciousSearchMixin(LitElement) ) { static override styles = [ searchBarInputAndButtonStyle, @@ -83,6 +85,13 @@ export class SearchaliciousBar extends SuggestionSelectionMixin( `, ]; + /** + * The options for the suggestion. + * + * We redefine them to use SuggestOption + */ + @property({attribute: false, type: Array}) + override options: SuggestOption[] = []; /** * Placeholder attribute is stored in a private variable to be able to use the msg() function * it stores the placeholder attribute value if it is set @@ -90,11 +99,8 @@ export class SearchaliciousBar extends SuggestionSelectionMixin( */ private _placeholder?: string; - /** - * Taxonomies we want to use for suggestions - */ - @property({type: String, attribute: 'suggestions'}) - suggestions = ''; + @state() + suggesters?: SearchaliciousSuggester[] = []; /** * Place holder in search bar @@ -117,19 +123,43 @@ export class SearchaliciousBar extends SuggestionSelectionMixin( return this.isQueryChanged || this.isFacetsChanged; } - /** - * It parses the string suggestions attribute and returns an array - */ - get parsedSuggestions() { - return this.suggestions.split(','); - } - constructor() { super(); - - // allow to set the locale from the browser // @ts-ignore - window.setLocale = setLocale; + if (!window.setLocale) { + // allow to set the locale from the browser + // @ts-ignore + window.setLocale = setLocale; + } + } + + override firstUpdated(changedProperties: PropertyValues) { + super.firstUpdated(changedProperties); + // this is the good time to register + this.suggesters = [ + ...this.querySelectorAll('searchalicious-taxonomy-suggest'), + ]; + this.suggesters.forEach((suggester) => (suggester.searchCtl = this)); + } + + /** Ask suggesters for suggested options */ + async getSuggestions(value: string) { + if (this.suggesters === undefined) { + throw new Error('No suggesters registered'); + } + return Promise.allSettled( + this.suggesters.map((suggester) => { + return (suggester as SearchaliciousSuggester).getSuggestions(value); + }) + ).then((optionsLists) => { + const options: SuggestOption[] = []; + optionsLists.forEach((result) => { + if (result.status === 'fulfilled' && result.value != null) { + options.push(...(result.value as SuggestOption[])); + } + }); + return options; + }); } /** @@ -138,16 +168,12 @@ export class SearchaliciousBar extends SuggestionSelectionMixin( * @param value */ override handleInput(value: string) { - this.value = value; this.query = value; this.updateSearchSignals(); this.debounce(() => { - this.getTaxonomiesTerms(value, this.parsedSuggestions).then(() => { - this.options = this.terms.map((term) => ({ - value: term.text, - label: term.text, - })); + this.getSuggestions(value).then((options) => { + this.options = options; }); }); } @@ -155,17 +181,16 @@ export class SearchaliciousBar extends SuggestionSelectionMixin( /** * Submit - might either be selecting a suggestion or submitting a search expression */ - override submit(isSuggestion?: boolean) { + override submitSuggestion(isSuggestion?: boolean) { // If the value is a suggestion, select the term and reset the input otherwise search if (isSuggestion) { - this.selectTermByTaxonomy( - this.terms[this.getOptionIndex].taxonomy_name, - removeLangFromTermId(this.terms[this.getOptionIndex].id) - ); - this.resetInput(); - this.query = ''; + const selectedOption = this.selectedOption as SuggestOption; + selectedOption!.source.selectSuggestion(selectedOption); + this.resetInput(this.selectedOption); + this.selectedOption = undefined; + this.query = ''; // not sure if we should instead put the value of remaining input } else { - this.query = this.value; + this.query = this.selectedOption?.value || ''; this.blurInput(); } this.search(); @@ -176,21 +201,19 @@ export class SearchaliciousBar extends SuggestionSelectionMixin( */ renderSuggestions() { // Don't show suggestions if the input is not focused or the value is empty or there are no suggestions - if (!this.visible || !this.value || this.terms.length === 0) { + if (!this.visible || !this.query || this.options.length === 0) { return html``; } return html`
          - ${this.terms.map( - (term, index) => html` + ${this.options.map( + (option, index) => html`
        • - + ${option.source.renderSuggestion(option, index)}
        • ` )} diff --git a/frontend/src/search-chart.ts b/frontend/src/search-chart.ts index 791f77cc..36dfa5e1 100644 --- a/frontend/src/search-chart.ts +++ b/frontend/src/search-chart.ts @@ -4,17 +4,9 @@ import {customElement, property} from 'lit/decorators.js'; import {WHITE_PANEL_STYLE} from './styles'; import {SearchResultDetail} from './signals'; +import {ChartSearchParam} from './interfaces/chart-interfaces'; import {SearchaliciousResultCtlMixin} from './mixins/search-results-ctl'; -interface ChartSearchParamPOST { - chart_type: string; - field?: string; - x?: string; - y?: string; -} - -export type ChartSearchParam = ChartSearchParamPOST | string; - // eslint raises error due to :any // eslint-disable-next-line declare const vega: any; diff --git a/frontend/src/search-facets.ts b/frontend/src/search-facets.ts index cd6a2c86..8cf75cdf 100644 --- a/frontend/src/search-facets.ts +++ b/frontend/src/search-facets.ts @@ -1,16 +1,22 @@ -import {LitElement, html, nothing, css} from 'lit'; +import {LitElement, html, nothing, css, PropertyValues} from 'lit'; import {customElement, property, queryAssignedNodes} from 'lit/decorators.js'; import {repeat} from 'lit/directives/repeat.js'; import {DebounceMixin} from './mixins/debounce'; import {SearchaliciousTermsMixin} from './mixins/suggestions-ctl'; -import {getTaxonomyName, removeLangFromTermId} from './utils/taxonomies'; +import { + getTaxonomyName, + removeLangFromTermId, + unquoteTerm, +} from './utils/taxonomies'; import {SearchActionMixin} from './mixins/search-action'; import {FACET_TERM_OTHER} from './utils/constants'; import {QueryOperator, SearchaliciousEvents} from './utils/enums'; import {getPluralTranslation} from './localization/translations'; import {msg, localized} from '@lit/localize'; import {WHITE_PANEL_STYLE} from './styles'; +import {SearchaliciousFacetsInterface} from './interfaces/facets-interfaces'; import {SearchaliciousResultCtlMixin} from './mixins/search-results-ctl'; +import {SearchCtlGetMixin} from './mixins/search-ctl-getter'; interface FacetsInfos { [key: string]: FacetInfo; @@ -29,6 +35,7 @@ interface FacetItem { interface FacetTerm extends FacetItem { count: number; + selected: boolean; } interface PresenceInfo { @@ -46,9 +53,10 @@ function stringGuard(s: string | undefined): s is string { */ @customElement('searchalicious-facets') @localized() -export class SearchaliciousFacets extends SearchaliciousResultCtlMixin( - SearchActionMixin(LitElement) -) { +export class SearchaliciousFacets + extends SearchaliciousResultCtlMixin(SearchActionMixin(LitElement)) + implements SearchaliciousFacetsInterface +{ static override styles = css` .reset-button-wrapper { display: flex; @@ -183,7 +191,7 @@ export class SearchaliciousFacet extends LitElement { @property() name = ''; - // the last search infor for my facet + // the last search infos for my facet @property({attribute: false}) // eslint-disable-next-line @typescript-eslint/no-explicit-any infos?: FacetInfo; @@ -241,7 +249,9 @@ export class SearchaliciousFacet extends LitElement { @customElement('searchalicious-facet-terms') @localized() export class SearchaliciousTermsFacet extends SearchActionMixin( - SearchaliciousTermsMixin(DebounceMixin(SearchaliciousFacet)) + SearchaliciousTermsMixin( + SearchCtlGetMixin(DebounceMixin(SearchaliciousFacet)) + ) ) { static override styles = [ WHITE_PANEL_STYLE, @@ -284,6 +294,15 @@ export class SearchaliciousTermsFacet extends SearchActionMixin( @property({attribute: 'show-other', type: Boolean}) showOther = false; + /** + * Interrogation language for suggestion + * + * We use the same as the search-bar + */ + override get langs() { + return this.getSearchCtl().langs; + } + _launchSearchWithDebounce = () => this.debounce(() => { this._launchSearch(); @@ -292,6 +311,8 @@ export class SearchaliciousTermsFacet extends SearchActionMixin( * Set wether a term is selected or not */ override setTermSelected(checked: boolean, name: string) { + // remove quotes if needed + name = unquoteTerm(name); this.selectedTerms = { ...this.selectedTerms, ...{[name]: checked}, @@ -324,7 +345,7 @@ export class SearchaliciousTermsFacet extends SearchActionMixin( override setSelectedTerms(terms?: string[]) { this.selectedTerms = {}; for (const term of terms ?? []) { - this.selectedTerms[term] = true; + this.selectedTerms[unquoteTerm(term)] = true; } } @@ -335,9 +356,9 @@ export class SearchaliciousTermsFacet extends SearchActionMixin( let values = Object.keys(this.selectedTerms).filter( (key) => this.selectedTerms[key] ); - // add quotes if we have ":" in values + // add quotes if we have ":" or "-" in values values = values.map((value) => - value.includes(':') ? `"${value}"` : value + !value.match(/^\w+$/) ? `"${value}"` : value ); if (values.length === 0) { return undefined; @@ -381,7 +402,9 @@ export class SearchaliciousTermsFacet extends SearchActionMixin( const options = (this.terms || []).map((term) => { return { value: removeLangFromTermId(term.id), - label: term.text, + label: this.termLabel(term), + id: term.id, + input: term.input, }; }); @@ -423,7 +446,7 @@ export class SearchaliciousTermsFacet extends SearchActionMixin(
          @@ -451,6 +474,21 @@ export class SearchaliciousTermsFacet extends SearchActionMixin( search && this._launchSearchWithDebounce(); }; + protected override willUpdate(_changedProperties: PropertyValues): void { + super.willUpdate(_changedProperties); + if (_changedProperties.has('infos')) { + // recompute selectedTerms + this.selectedTerms = {}; + if (this.infos) { + this.infos.items.forEach((item) => { + if ((item as FacetTerm).selected) { + this.selectedTerms[item.key] = true; + } + }); + } + } + } + /** * Renders the facet content */ diff --git a/frontend/src/search-sort.ts b/frontend/src/search-sort.ts index d5dfa2f6..d1ddedf5 100644 --- a/frontend/src/search-sort.ts +++ b/frontend/src/search-sort.ts @@ -1,15 +1,14 @@ import {css, LitElement, html, nothing} from 'lit'; import {customElement, property, state} from 'lit/decorators.js'; +import { + SortParameters, + SearchaliciousSortInterface, +} from './interfaces/sort-interfaces'; import {SearchActionMixin} from './mixins/search-action'; import {EventRegistrationMixin} from './event-listener-setup'; import {SearchaliciousEvents} from './utils/enums'; -export interface SortParameters { - sort_by?: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sort_params?: Record; -} /** * A component to enable user to choose a search order. * @@ -21,9 +20,10 @@ export interface SortParameters { * @cssproperty --sort-options-background-color - The background color of the options.t */ @customElement('searchalicious-sort') -export class SearchaliciousSort extends SearchActionMixin( - EventRegistrationMixin(LitElement) -) { +export class SearchaliciousSort + extends SearchActionMixin(EventRegistrationMixin(LitElement)) + implements SearchaliciousSortInterface +{ static override styles = css` .options { list-style: none; diff --git a/frontend/src/search-suggester.ts b/frontend/src/search-suggester.ts new file mode 100644 index 00000000..a9c227d6 --- /dev/null +++ b/frontend/src/search-suggester.ts @@ -0,0 +1,153 @@ +/** + * Search suggester is an element that should be placed inside a search-bar component. + * It indicates some suggestion that the search bar should apply + * + */ +import {LitElement, TemplateResult, html} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; +import {SearchaliciousTermsMixin} from './mixins/suggestions-ctl'; +import {SearchaliciousSearchInterface} from './interfaces/search-ctl-interfaces'; +import { + SuggestionSelectionOption, + SuggestOption, +} from './interfaces/suggestion-interfaces'; +import {SearchaliciousFacetsInterface} from './interfaces/facets-interfaces'; +import {removeLangFromTermId} from './utils/taxonomies'; + +export class SearchaliciousSuggester extends LitElement { + @property({attribute: false}) + searchCtl: SearchaliciousSearchInterface | undefined; + + /** + * Query for options to suggest for value and return them + */ + async getSuggestions(_value: string): Promise { + throw new Error('Not implemented, implement in child class'); + } + + /** + * Select a suggestion + */ + async selectSuggestion(_selected: SuggestionSelectionOption) { + throw new Error('Not implemented, implement in child class'); + } + + /** + * Render a suggestion + */ + renderSuggestion(_suggestion: SuggestOption, _index: number): TemplateResult { + throw new Error('Not implemented, implement in child class'); + } +} + +/** + * Selection Option for taxonomy suggestions + */ +type taxonomySelectionOption = SuggestionSelectionOption & { + /** + * taxonomy related to this suggestion + */ + taxonomy: string; +}; + +/** + * An element to be used inside a searchalicious-bar component. + * It enables giving suggestions that are based upon taxonomies + */ +@customElement('searchalicious-taxonomy-suggest') +export class SearchaliciousTaxonomySuggester extends SearchaliciousTermsMixin( + SearchaliciousSuggester +) { + // TODO: suggestion should be by type + // for that we need for example to use slot by taxonomy where we put the value + // this would enable adding beautiful html selections + + /** + * Taxonomies we want to use for suggestions + */ + @property({type: String, attribute: 'taxonomies'}) + taxonomies = ''; + + /** + * Fuzziness level for suggestions + */ + @property({type: Number}) + fuzziness = 2; + + /** + * language in which suggestions are to be found + */ + override get langs(): string { + if (!this.searchCtl) { + throw Error('Asking for langs while searchCtl is not yet registered'); + } + return this.searchCtl.langs; + } + + /** + * taxonomies attribute but as an array of String + */ + get taxonomiesList() { + return this.taxonomies.split(','); + } + + /** + * Select a term by taxonomy in all facets + * It will update the selected terms in facets + * @param taxonomy + * @param term + */ + selectTermByTaxonomy(taxonomy: string, term: string) { + for (const facets of this.searchCtl!.relatedFacets()) { + // if true, the facets has been updated + if ( + (facets as SearchaliciousFacetsInterface).selectTermByTaxonomy( + taxonomy, + term + ) + ) { + return; + } + } + // TODO: handle the case of no facet found: replace expression with a condition on specific field + } + + /** + * Query for options to suggest + */ + override async getSuggestions(value: string): Promise { + return this.getTaxonomiesTerms(value, this.taxonomiesList).then(() => { + return this.terms.map((term) => ({ + // we need to remove lang for some pseudo taxonomy fields don't have them… + value: removeLangFromTermId(term.id), + label: this.termLabel(term), + id: term.id, + source: this, + input: value, + taxonomy: term.taxonomy_name, + })); + }); + } + + override async selectSuggestion(selected: SuggestionSelectionOption) { + this.selectTermByTaxonomy( + (selected as taxonomySelectionOption).taxonomy, + selected.value + ); + } + + override renderSuggestion( + suggestion: SuggestOption, + _index: number + ): TemplateResult { + return html``; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'searchalicious-taxonomy-suggest': SearchaliciousTaxonomySuggester; + } +} diff --git a/frontend/src/search-suggestion-entry.ts b/frontend/src/search-suggestion-entry.ts index f1fdf25e..64acdafb 100644 --- a/frontend/src/search-suggestion-entry.ts +++ b/frontend/src/search-suggestion-entry.ts @@ -1,5 +1,6 @@ import {css, html, LitElement} from 'lit'; import {customElement, property} from 'lit/decorators.js'; +import {SuggestionSelectionOption} from './interfaces/suggestion-interfaces'; /** * This component represent a suggestion to the user as he types his search. @@ -28,26 +29,12 @@ export class SearchaliciousSuggestionEntry extends LitElement { --img-size: var(--searchalicious-suggestion-entry-img-size, 2rem); } - .suggestion-entry .suggestion-entry-img-wrapper { - width: var(--img-size); - height: var(--img-size); - overflow: hidden; - } - .suggestion-entry .suggestion-entry-text-wrapper { --margin-left: 1rem; margin-left: var(--margin-left); width: calc(100% - var(--img-size) - var(--margin-left)); } - .suggestion-entry-img-wrapper > * { - width: 100%; - height: 100%; - object-fit: cover; - border-radius: 50%; - background-color: var(--img-background-color, #d9d9d9); - } - .suggestion-entry-text { font-weight: bold; text-overflow: ellipsis; @@ -58,12 +45,7 @@ export class SearchaliciousSuggestionEntry extends LitElement { `; @property({type: Object, attribute: 'term'}) - term?: { - imageUrl?: string; - id: string; - text: string; - taxonomy_name: string; - }; + term?: SuggestionSelectionOption; /** * We display the taxonomy term and corresponding filter name @@ -71,15 +53,8 @@ export class SearchaliciousSuggestionEntry extends LitElement { override render() { return html`
          -
          - - ${this.term?.imageUrl - ? html`` - : html`
          `} -
          -
          ${this.term?.text}
          -
          ${this.term?.taxonomy_name}
          +
          ${this.term?.label}
          `; diff --git a/frontend/src/utils/taxonomies.ts b/frontend/src/utils/taxonomies.ts index 7baa7d44..2faecadc 100644 --- a/frontend/src/utils/taxonomies.ts +++ b/frontend/src/utils/taxonomies.ts @@ -14,3 +14,8 @@ export const getTaxonomyName = (taxonomy: string): string => { export const removeLangFromTermId = (termId: string): string => { return termId.replace(/^[a-z]{2}:/, ''); }; + +/** unquote a term */ +export const unquoteTerm = (term: string): string => { + return term.replace(/^"(.*)"$/, '$1'); +}; diff --git a/scripts/Dockerfile.sphinx b/scripts/Dockerfile.sphinx index 1aac9afe..b6383ed4 100644 --- a/scripts/Dockerfile.sphinx +++ b/scripts/Dockerfile.sphinx @@ -1,5 +1,5 @@ # in your Dockerfile -FROM sphinxdoc/sphinx:latest +FROM sphinxdoc/sphinx:7.4.7 ARG USER_UID=1000 ARG USER_GID=1000 @@ -11,12 +11,12 @@ RUN addgroup --gid $USER_GID user && adduser --uid $USER_UID --ingroup user --no RUN mkdir -p /docs/build && mkdir -p /docs/source && chown user:user /docs # install poetry, and export dependencies as a requirement.txt COPY poetry.lock pyproject.toml ./ -RUN apt update && apt install -y curl +RUN apt update && apt install -y curl cargo RUN ( curl -sSL https://install.python-poetry.org | python3 - ) && \ /root/.local/bin/poetry self add poetry-plugin-export && \ /root/.local/bin/poetry export --output requirements.txt # install those dependencies -RUN pip install -r requirements.txt +RUN pip install -U pip && pip install -r requirements.txt # install some useful plugin for sphinx RUN pip install autodoc_pydantic sphinxcontrib-typer USER user diff --git a/scripts/build_mkdocs.sh b/scripts/build_mkdocs.sh index 27363b55..0b14417f 100755 --- a/scripts/build_mkdocs.sh +++ b/scripts/build_mkdocs.sh @@ -1,4 +1,6 @@ #!/usr/bin/env bash +echo '::group::{build_mkdocs}' + set -e # Renders markdown doc in docs to html in gh_pages @@ -12,4 +14,6 @@ docker build --build-arg "USER_UID=$UID" --build-arg "USER_GID=$GID" --tag 'mkdo docker run --rm \ -e USER_ID=$UID -e GROUP_ID=$GID \ -v $(pwd):/app -w /app \ - mkdocs-builder build --strict \ No newline at end of file + mkdocs-builder build --strict + +echo "::endgroup::" \ No newline at end of file diff --git a/scripts/build_schema.sh b/scripts/build_schema.sh index 87ba91a2..12c99b0e 100755 --- a/scripts/build_schema.sh +++ b/scripts/build_schema.sh @@ -2,6 +2,8 @@ # Build config documentation in markdown # Use it before using mkdocs +echo "::group::{build_schema $1}" + # Parameter is the schema type: config / settings SCHEMA=$1 @@ -32,3 +34,5 @@ docker run --rm --user user \ mv build/ref-$SCHEMA/* gh_pages/users/ref-$SCHEMA/ # also source cp data/searchalicious-$SCHEMA-schema.yml gh_pages/users/ref-$SCHEMA/ + +echo "::endgroup::" \ No newline at end of file diff --git a/scripts/build_sphinx.sh b/scripts/build_sphinx.sh index f87cae26..676760c5 100755 --- a/scripts/build_sphinx.sh +++ b/scripts/build_sphinx.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash # Build sphinx documentation +echo '::group::{build_sphinx}' + set -e # get group id to use it in the docker @@ -26,3 +28,5 @@ docker run --rm --user user \ rm -rf gh_pages/devs/ref-python || true mv gh_pages/sphinx/html gh_pages/devs/ref-python rm -rf gh_pages/sphinx/ + +echo "::endgroup::" \ No newline at end of file diff --git a/scripts/generate_doc.sh b/scripts/generate_doc.sh index c3a26231..9197f3e8 100755 --- a/scripts/generate_doc.sh +++ b/scripts/generate_doc.sh @@ -16,20 +16,26 @@ scripts/build_schema.sh config scripts/build_schema.sh settings echo "Generate OpenAPI documentation" +echo '::group::{generate_openapi}' make generate-openapi +echo "::endgroup::" echo "Generate openapi html with redocly" +echo '::group::{generate_openapi_html}' docker run --rm \ -v $(pwd)/data:/data -v $(pwd)/gh_pages/:/output \ ghcr.io/redocly/redoc/cli:latest \ build -o /output/users/ref-openapi/index.html searchalicious-openapi.yml sudo chown $UID -R gh_pages/users/ref-openapi +echo "::endgroup::" echo "Generate webcomponents documentation" +echo '::group::{generate_custom_elements}' make generate-custom-elements mkdir -p gh_pages/users/ref-web-components/dist cp frontend/public/dist/custom-elements.json gh_pages/users/ref-web-components/dist/custom-elements.json sudo chown $UID -R gh_pages/users/ref-web-components +echo "::endgroup::" echo "Generate python code documentation using sphinx" scripts/build_sphinx.sh diff --git a/tests/int/data/test_off.yml b/tests/int/data/test_off.yml index 3a17f8ef..9800e470 100644 --- a/tests/int/data/test_off.yml +++ b/tests/int/data/test_off.yml @@ -50,6 +50,7 @@ indices: primary_color: "#341100" accent_color: "#ff8714" taxonomy: + preprocessor: tests.int.helpers.TestTaxonomyPreprocessor sources: - name: categories url: file:///opt/search/tests/int/data/test_categories.full.json diff --git a/tests/int/helpers.py b/tests/int/helpers.py index 983811c6..35573e48 100644 --- a/tests/int/helpers.py +++ b/tests/int/helpers.py @@ -5,7 +5,9 @@ from app._import import BaseDocumentFetcher from app._types import FetcherResult, FetcherStatus, JSONType from app.indexing import BaseDocumentPreprocessor +from app.openfoodfacts import TaxonomyPreprocessor from app.postprocessing import BaseResultProcessor +from app.taxonomy import Taxonomy, TaxonomyNode, TaxonomyNodeResult class CallRegistration: @@ -41,6 +43,13 @@ def get_calls(cls): return calls +class TestTaxonomyPreprocessor(TaxonomyPreprocessor, CallRegistration): + + def preprocess(self, taxonomy: Taxonomy, node: TaxonomyNode) -> TaxonomyNodeResult: + self.register_call((taxonomy.name, node.id)) + return super().preprocess(taxonomy, node) + + class TestDocumentFetcher(BaseDocumentFetcher, CallRegistration): def fetch_document(self, stream_name: str, item: JSONType) -> FetcherResult: diff --git a/tests/int/test_completion.py b/tests/int/test_completion.py new file mode 100644 index 00000000..75eb022e --- /dev/null +++ b/tests/int/test_completion.py @@ -0,0 +1,73 @@ +import pytest + + +@pytest.mark.parametrize( + "q,taxonomies,langs,results", + [ + # simple + ("organ", "labels", "en", [("en:organic", "Organic", 90)]), + # no case match + ("ORGAN", "labels", "en", [("en:organic", "Organic", 90)]), + # french + ("biol", "labels", "fr", [("en:organic", "biologique", 90)]), + # multiple languages + ("biol", "labels", "en,fr", [("en:organic", "biologique", 90)]), + # xx added to french + ("Max H", "labels", "fr", [("en:max-havelaar", "Max Havelaar", 85)]), + # main for an entry without french + ( + "Fairtrade/Max H", + "labels", + "fr,main", + [("en:max-havelaar", "Fairtrade/Max Havelaar", 85)], + ), + # multiple taxonomies + ( + "fr", + "labels,categories", + "en", + [ + ("en:organic", "From Organic Agriculture", 90), + ("en:fr-bio-01", "FR-BIO-01", 88), + ("en:no-artificial-flavors", "free of artificial flavor", 76), + ( + "en:fruits-and-vegetables-based-foods", + "Fruits and vegetables based foods", + 64, + ), + ], + ), + # different answers + ( + "b", + "categories", + "en", + [ + ("en:biscuits", "biscuit", 89), + ("en:beverages", "Beverages", 88), + ("en:chocolate-biscuits", "Biscuit with chocolate", 79), + ("en:biscuits-and-cakes", "Biscuits and cakes", 79), + ("en:sweetened-beverages", "Beverages with added sugar", 78), + ], + ), + ], +) +def test_completion(q, taxonomies, langs, results, test_client, synonyms_created): + response = test_client.get( + f"/autocomplete?q={q}&langs={langs}&taxonomy_names={taxonomies}&size=5" + ) + assert response.status_code == 200 + options = response.json()["options"] + assert len(options) == len(results) + # only requested taxonomies + result_taxonomies = set([option["taxonomy_name"] for option in options]) + assert result_taxonomies <= set(taxonomies.split(",")) + # well sorted + assert sorted([option["score"] for option in options], reverse=True) == [ + option["score"] for option in options + ] + # expected results + completions = [ + (option["id"], option["text"], int(option["score"])) for option in options + ] + assert completions == results