view libervia/web/pages/_browser/jid_search.py @ 1618:5d9889f14012 default tip @

server: start major redesign - Add icons to menu items - Switch menu items representation from tuple to dictionary for future extensibility: - Include icon information - Prepare for additional data - Remove "login" from main menu, add login page URL to template data, as it is now a separate right-aligned item
author Goffi <goffi@goffi.org>
date Sat, 26 Oct 2024 23:07:01 +0200
parents 4a9679369856
children
line wrap: on
line source

import json
from urllib.parse import quote, urljoin

from bridge import AsyncBridge as Bridge
from browser import aio, console as log, window
from cache import cache
from template import Template
import jid

log.warning = log.warn
profile = window.profile or ""
bridge = Bridge()

class JidSearch:

    def __init__(
        self,
        search_elt,
        container_elt=None,
        filter_cb=None,
        empty_cb=None,
        get_url=None,
        click_cb=None,
        options: dict|None = None,
        submit_filter: bool = True,
        template: str = "components/search_item.html",
        allow_multiple_selection: bool = False,
        selected_item_class: str = 'is-selected-search_item',
        selection_state_callback=None
    ) -> None:
        """Initialize the JidSearch instance

        @param search_elt: The HTML <input> element for search
        @param container_elt: The HTML container to display the search results
        @param filter_cb: The callback to filter the search results
        @param empty_cb: The callback when the search box is empty
        @param get_url: The function to get URL for each entity in the search result
        @param click_cb: The function to handle the click event on each entity in the
            search result
        @param options: extra options. Key can be:
            no_group(bool)
                True if groups should not be visible
            extra_cb(dict)
                a map from CSS selector to callback, the callback will be binded to the
                "click" event, and will be called with the ``item`` as argument
        @param submit_filter: if True, only submit when a seemingly valid JID is entered
        @param template: template to use
        @param allow_multiple_selection: If True, allows multiple search entities to be
            selected with checkboxes.
        @param selected_item_class: The CSS class to apply when an item is selected. This
            class should define distinctive styles to highlight selected items.
        @param selection_state_callback: A callback function to execute when selection
            state changes. Takes one boolean parameter indicating if items are selected.
        """
        self.search_item_tpl = Template(template)
        self.search_elt = search_elt
        self.search_elt.bind("input", self.on_search_input)
        if submit_filter:
            try:
                form_elt = self.search_elt.closest("form")
            except KeyError:
                log.debug("No parent <form> found, can't apply submit filter.")
            else:
                form_elt.bind("submit", self.on_form_submit)
        self.last_query = None
        self.current_query = None
        self.container_elt = container_elt
        if options is None:
            options = {}
        self.options = options

        if click_cb is not None and get_url is None:
            self.get_url = lambda _: "#"
        else:
            self.get_url = get_url if get_url is not None else self.default_get_url
        self.click_cb = click_cb

        if filter_cb is None:
            if container_elt is None:
                raise ValueError("container_elt must be set if filter_cb is not set")
            filter_cb = self.show_items
        self.filter_cb = filter_cb

        self.empty_cb = empty_cb or self.on_empty_search

        self.allow_multiple_selection = allow_multiple_selection
        self.selected_item_class = selected_item_class
        if selection_state_callback is None:
            selection_state_callback = self.default_selection_state_callback
        self.selection_state_callback = selection_state_callback
        self._selected_jids = set()
        self.has_selection = False
        self.update_selection_state()

        current_search = search_elt.value.strip() or None
        if not current_search:
            aio.run(self.empty_cb(self))
        else:
            aio.run(self.perform_search(current_search))

    @property
    def selected_jids(self) -> list[str]:
        return list(self._selected_jids)

    def default_get_url(self, item):
        """Default method to get the URL for a given entity

        @param item: The item (entity) for which the URL is required
        """
        return urljoin(f"{window.location.href}/", quote(item["entity"]))

    def default_selection_state_callback(self, has_selection: bool) -> None:
        """Default callback to handle selection state changes.

        @param has_selection: Boolean indicating if any items are currently selected.
        """
        if has_selection:
            self.container_elt.classList.add('has-selected-items')
        else:
            self.container_elt.classList.remove('has-selected-items')

    def update_selection_state(self):
        """
        Checks the selection state and triggers the callback accordingly.
        """
        current_has_selection = bool(self._selected_jids)
        if current_has_selection != self.has_selection:
            self.has_selection = current_has_selection
            self.selection_state_callback(self.has_selection)

    def show_items(self, items):
        """Display the search items in the specified container

        @param items: The list of search items to be displayed
        """
        assert self.container_elt is not None
        self.container_elt.clear()
        for item in items:
            search_item_elt = self.search_item_tpl.get_elt({
                "url": self.get_url(item),
                "item": item,
                "multiple_selection": self.allow_multiple_selection,
                "identities": cache.identities,
                "options": self.options,
            })

            if self.allow_multiple_selection:
                # Include a checkbox and manage its state
                checkbox = search_item_elt.select('.search-item__checkbox')[0]
                checkbox.checked = item['entity'] in self._selected_jids
                checkbox.bind('change', lambda evt, item=item: self.on_checkbox_change(evt, item))
                checkbox.bind('click', lambda evt: evt.stopPropagation())

                # Highlight item if selected
                if item['entity'] in self._selected_jids:
                    card = search_item_elt.select('.card')[0]
                    card.classList.add(self.selected_item_class)

            if self.click_cb is not None:
                # Make the whole item clickable and perform the click callback function
                search_item_elt.bind('click', lambda evt, item=item: self.click_cb(evt, item))

            # Now append the whole element to the container
            self.container_elt <= search_item_elt

            # Apply extra callbacks if defined in options
            extra_cb = self.options.get("extra_cb")
            if extra_cb:
                for selector, cb in extra_cb.items():
                    for elt in search_item_elt.select(selector):
                        elt.bind("click", lambda evt, item=item, cb=cb: cb(evt, item))

    def on_search_input(self, evt):
        """Handle the 'input' event for the search element

        @param evt: The event object
        """
        evt.stopPropagation()
        evt.preventDefault()
        search_text = evt.target.value.strip()
        if not search_text:
            aio.run(self.empty_cb(self))
        elif len(search_text) > 2:
            aio.run(self.perform_search(search_text))

    def on_form_submit(self, evt):
        search_text = self.search_elt.value.strip()
        search_jid = jid.JID(search_text)
        if not search_jid.is_valid():
            evt.stopPropagation()
            evt.preventDefault()

    async def perform_search(self, query):
        if self.current_query is None:
            log.debug(f"performing search: {query=}")
            self.current_query = query
            jid_items = json.loads(await bridge.jid_search(query, ""))
            await cache.fill_identities(i["entity"] for i in jid_items)

            self.last_query = query
            self.current_query = None
            current_query = self.search_elt.value.strip()

            if current_query != query:
                await self.perform_search(current_query)
                return

            # Include selected items in the search result regardless of the filter
            jid_items_set = {item['entity']: item for item in jid_items}
            for jid in self._selected_jids:
                if jid not in jid_items_set:
                    jid_items_set[jid] = {"entity": jid}

            # Union of search results and manually added selected items
            filtered_items = [
                jid_items_set[jid] for jid in jid_items_set
            ]

            self.filter_cb(filtered_items)

    async def on_empty_search(self, jid_search):
        """Handle the situation when the search box is empty"""
        assert self.container_elt is not None
        items = [
            {
                "entity": jid_,
                "groups": data["groups"]
            }
            for jid_, data in cache.roster.items()
        ]
        self.show_items(items)

    def on_checkbox_change(self, evt, item):
        log.debug(f"checkbox_change {evt.target=} {item=}")
        evt.stopPropagation()
        cb = evt.target

        if cb.checked:
            self._selected_jids.add(item['entity'])
            cb.closest('.card').classList.add(self.selected_item_class)
        else:
            self._selected_jids.remove(item['entity'])
            cb.closest('.card').classList.remove(self.selected_item_class)

        self.update_selection_state()