From 0208d64e86dd0e89e1b9a09704638b60f5af6e85 Mon Sep 17 00:00:00 2001 From: AlexCLeduc Date: Fri, 22 Nov 2024 10:10:39 -0500 Subject: [PATCH] feat!: major API and routing changes (#60) * split out sample-app * switch to pytest * update vscode python settings * Add model autocomplete * split up views from AC class core * Add auth check * Add client-side helper class to clear values --- .vscode/settings.json | 12 +- README.md | 319 ++++++--- autocomplete/__init__.py | 6 +- autocomplete/autocomplete.py | 653 ------------------ autocomplete/core.py | 115 +++ autocomplete/locale/en/LC_MESSAGES/django.po | 40 +- autocomplete/locale/fr/LC_MESSAGES/django.po | 50 +- autocomplete/shortcuts.py | 96 +++ .../static/autocomplete/js/autocomplete.js | 59 ++ autocomplete/templates/autocomplete/chip.html | 12 +- .../templates/autocomplete/component.html | 2 +- autocomplete/templates/autocomplete/item.html | 13 +- .../templates/autocomplete/item_list.html | 31 +- .../autocomplete/strings/more_results.html | 6 - .../autocomplete/strings/no_results.html | 2 - .../templates/autocomplete/textinput.html | 12 +- .../templates/autocomplete/values.html | 5 +- autocomplete/templatetags/autocomplete.py | 133 +++- autocomplete/test_autocomplete.py | 25 - autocomplete/views.py | 189 +++++ autocomplete/widgets.py | 170 ++--- tests/app/manage.py => manage.py | 10 +- pytest.ini | 4 + requirements.txt | 9 +- {tests/app/ac_test => sample_app}/__init__.py | 0 {tests/app/ac_test => sample_app}/admin.py | 0 {tests/app/ac_test => sample_app}/apps.py | 4 +- {tests/app/app => sample_app}/asgi.py | 2 +- sample_app/dev_script.py | 21 + .../migrations/0001_initial.py | 30 +- .../migrations/__init__.py | 0 sample_app/models.py | 31 + {tests/app/app => sample_app}/settings.py | 10 +- sample_app/templates/_generic_form.html | 5 + sample_app/templates/base.html | 35 + sample_app/templates/edit_team.html | 26 + .../templates/index.html | 36 +- {tests/app/ac_test => sample_app}/tests.py | 0 {tests/app/app => sample_app}/urls.py | 16 +- sample_app/views.py | 132 ++++ {tests/app/app => sample_app}/wsgi.py | 2 +- tests/{app/app => }/__init__.py | 0 tests/app/ac_test/ac_controls.py | 108 --- tests/app/ac_test/forms.py | 113 --- tests/app/ac_test/models.py | 13 - tests/app/ac_test/views.py | 31 - tests/conftest.py | 33 + tests/pytest_test_runner.py | 50 ++ tests/test_auth_check.py | 74 ++ tests/test_items.py | 287 ++++++++ tests/test_model_ac.py | 52 ++ tests/test_toggle.py | 249 +++++++ tests/test_widget_render.py | 415 +++++++++++ tests/utils_for_test.py | 30 + 54 files changed, 2477 insertions(+), 1301 deletions(-) delete mode 100644 autocomplete/autocomplete.py create mode 100644 autocomplete/core.py create mode 100644 autocomplete/shortcuts.py delete mode 100644 autocomplete/templates/autocomplete/strings/more_results.html delete mode 100644 autocomplete/templates/autocomplete/strings/no_results.html delete mode 100644 autocomplete/test_autocomplete.py create mode 100644 autocomplete/views.py rename tests/app/manage.py => manage.py (69%) create mode 100644 pytest.ini rename {tests/app/ac_test => sample_app}/__init__.py (100%) rename {tests/app/ac_test => sample_app}/admin.py (100%) rename {tests/app/ac_test => sample_app}/apps.py (61%) rename {tests/app/app => sample_app}/asgi.py (81%) create mode 100644 sample_app/dev_script.py rename {tests/app/ac_test => sample_app}/migrations/0001_initial.py (63%) rename {tests/app/ac_test => sample_app}/migrations/__init__.py (100%) create mode 100644 sample_app/models.py rename {tests/app/app => sample_app}/settings.py (94%) create mode 100644 sample_app/templates/_generic_form.html create mode 100644 sample_app/templates/base.html create mode 100644 sample_app/templates/edit_team.html rename {tests/app/ac_test => sample_app}/templates/index.html (72%) rename {tests/app/ac_test => sample_app}/tests.py (100%) rename {tests/app/app => sample_app}/urls.py (64%) create mode 100644 sample_app/views.py rename {tests/app/app => sample_app}/wsgi.py (81%) rename tests/{app/app => }/__init__.py (100%) delete mode 100644 tests/app/ac_test/ac_controls.py delete mode 100644 tests/app/ac_test/forms.py delete mode 100644 tests/app/ac_test/models.py delete mode 100644 tests/app/ac_test/views.py create mode 100644 tests/conftest.py create mode 100644 tests/pytest_test_runner.py create mode 100644 tests/test_auth_check.py create mode 100644 tests/test_items.py create mode 100644 tests/test_model_ac.py create mode 100644 tests/test_toggle.py create mode 100644 tests/test_widget_render.py create mode 100644 tests/utils_for_test.py diff --git a/.vscode/settings.json b/.vscode/settings.json index d69ce88..703353b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,11 +1,15 @@ { - "markdown-preview-github-styles.colorTheme": "light", "editor.rulers": [ 88 ], - "python.linting.pylintEnabled": true, - "python.linting.enabled": true, - "python.formatting.provider": "black", + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter", + "editor.tabSize": 4 + }, + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + }, "cSpell.words": [ "htmx", "textinput" diff --git a/README.md b/README.md index 30c335a..da9df57 100644 --- a/README.md +++ b/README.md @@ -1,135 +1,230 @@ # django-htmx-autocomplete -This Django app provides a client-side autocomplete component powered by +This Django app provides an autocomplete widiget component powered by [htmx](https://htmx.org/) featuring multiselect, search and is completely extensible. - ## Quick start 1. Add "autocomplete" to your `INSTALLED_APPS` setting like this: - ```python - # settings.py - INSTALLED_APPS = [ - ... - 'django.contrib.staticfiles', # also required - 'autocomplete', - ] - ``` + ```python + # settings.py + INSTALLED_APPS = [ + ... + 'django.contrib.staticfiles', # also required + 'autocomplete', + ] + ``` 1. Include the autocomplete urls like this: - ```python - # urls.py - ... - from autocomplete import HTMXAutoComplete - - urlpatterns = [ - ... - *HTMXAutoComplete.url_dispatcher('ac'), - ] - ``` - - This will add routes prefixed by `ac` to support component instances. - -1. Use either the widget or class to create components! - - ```python - from django forms - from django.db import models - from autocomplete import HTMXAutoComplete, widgets - - # Example models - class Person(models.Model): - name = models.CharField(max_length=60) - - class Team(models.Model): - name = models.CharField(max_length=60) - members = models.ManyToManyField(Person) - - # Using the widget - class MultipleFormModel(forms.ModelForm): - """Multiple select example form using a model""" - class Meta: - """Meta class that configures the form""" - model = Team - fields = ['name', 'members'] - widgets = { - 'members': widgets.Autocomplete( - name='members', - options=dict(multiselect=True, model=Person) - ) - } - - # Using the class - class GetItemsMultiAutoComplete(HTMXAutoComplete): - name = "members" - multiselect = True - - class Meta: - model = Person - - ``` + ```python + # urls.py + ... + from autocomplete import urls as autocomplete_urls + + urlpatterns = [ + # ... + path("ac/", autocomplete_urls), + ] + ``` + +1. Create an autocomplete class that extends `autocomplete.ModelAutocomplete`, + + ```python + from django forms + from django.db import models + from autocomplete import Autocomplete, AutocompleteWidget + + class Person(models.Model): + name = models.CharField(max_length=60) + + class Team(models.Model): + team_lead = models.ForeignKey( + Person, null=True, on_delete=models.SET_NULL, related_name="lead_teams" + ) + + members = models.ManyToManyField(Person) + + class PersonAutocomplete(ModelAutocomplete): + model = Person + search_attrs = [ 'name' ] + + + class MultipleFormModel(forms.ModelForm): + """Multiple select example form using a model""" + class Meta: + """Meta class that configures the form""" + model = Team + fields = ['team_lead', 'members'] + widgets = { + 'team_lead': AutocompleteWidget( + ac_class=PersonAutocomplete, + ), + 'members': AutocompleteWidget( + ac_class=PersonAutocomplete, + options={"multiselect": True}, + ) + } + ``` 1. Make sure your templates include HTMX. > **Note** > Bootstrap is included in this example styling, however it is not required. - ```django - {% load autocomplete %} - {% load static %} - - - - - - - -

Example base html template

- - - - - - - - - ``` - -## Customization - -### Strings - -The strings listed in the table below can be overriden by creating the appropriate -template in your own project, matching the `autocomplete/strings/{name}.html` pattern. -By default all strings are available in both French and English. - -| Name | Description | Default English | Default French | -| ----------------- | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------- | -| no_results | Text displayed when no results are found. | No results found. | Aucun résultat trouvé. | -| more_results | When `max_results` is set, text displayed when there are additional results available. | Displaying maximum {{ count }} out of {{ total_results }} results. | Affichage maximum de {{ count }} résultats sur {{ total_results }}. | -| available_results | Text anounced to sceen readers when results are available. If max_results is set, the more_results text is spoken instead. | {{ count }} results available. | {{ count }} résultats disponibles. | -| nothing_selected | Text anounced to screen readers when there are no selections. | Nothing selected. | Rien de sélectionné. | - -Individual instances can override strings by providing a dictionary of `custom_strings`. + ```django + {% load autocomplete %} + {% load static %} + + + + + + + +

Example base html template

+ + + + + + + + + ``` + +## Using the widget + +- The widget will receive its name, required-ness and disabled-ness from the form field, these work out of the box, including for formsets and prefixed forms. +- Options that can be tweaked on the fly via widget `options` are, + - multiselect + - If you want to use the widget in on a multiple-choice field (e.g. a many-to-many field), you can pass an options dict with `multiselect=True` in it + - placeholder + - default: "" + - component_prefix + - this is for a niche use-case where you want multiple inputs with the same `name` attribute. In that case and you don't set unique prefixes, the autocomplete widget may not work correctly due to duplicate HTML IDs. + +Other options are set less dynamically, by customizing the the autocomplete class... + +## Autocomplete class customization + +### `minimum_search_length` + +default: 3 + +example: + +```python +class MyAC(Autocomplete): + minimum_search_length = 2 +``` + +### `max_results` + +This library does not yet support pagination, but it will efficiently limit results and tell the user there how many results are missing. ```python - class GetItemsMultiAutoComplete(HTMXAutoComplete): - name = "members" - multiselect = True - custom_strings = { - "no_results": "no results text", - "more_results": _("More results text") - } +class MyAC(Autocomplete): + max_results = 10 + +``` + +### `component_prefix` + +- In addition to widget options, you can also set the `component_prefix` option on the class itself. Widget options will take precedence over the class. +- default: "" + +### `placeholder` + +- In addition to widget options, you can also set the `placeholder` option on the class itself. Widget options will take precedence over the class. +- default: "" + +### Translation strings + +You can customize the translation strings used in the autocomplete widget by overriding class variables on your autocomplete class, + +- `no_result_text` + - default: "No results found." +- `narrow_search_text` + - default: "Showing %(page_size)s of %(total)s items. Narrow your search for more results." +- `type_at_least_n_characters` + - default: "Type at least %(n)s characters" + +note that the `%(n)s` and `%(page_size)s` and `%(total)s` are placeholders that will be replaced with the actual values at runtime. If you write your own strings, make sure to use the `%(n)s` rather than `%(n)d`. Variables are converted to strings so the integer formatter will not work. + +example: + +```python +class MyAC(Autocomplete): + no_result_text = "No results found" + narrow_search_text = "Please narrow your search" + type_at_least_n_characters = "Type at least %(n)s characters" +``` + +### Authentication-aware behaviour + +Autocomplete adds 2 new views that any user, including non-authenticated users, can access. Autocomplete classes have a `auth_check` method you can override to add authentication checks. For example, if you want to restrict access to a certain autocomplete to only authenticated users, you can do the following, + +```python +class MyAC(Autocomplete): + # ... + + @staticmethod + def auth_check(request): + if not request.user.is_authenticated: + raise PermissionDenied("Must be logged in") + +``` + +This is a common enough use case that we've added a setting shortcut. Add `AUTOCOMPLETE_BLOCK_UNAUTHENTICATED=False` in your settings and all autocomplete views will require authentication by default. + +## Non model approach + +The model autocomplete is a subclass of the more generic `autocomplete.Autocomplete` class. You can use this class to create an autocomplete that does not rely on a model. There are two important methods to provide, + +1. `search_items(cls, search, context)` + - Must return an iterable of `{ key: string, label: string }` dictionaries. This iterable must allow slicing and len() to be called on it. +2. `get_items_from_keys(cls, keys, context)` + - Must return a list of `{ key: string, label: string }` dictionaries. This list must be the same length as the input keys list. + - This is used to render existing items in the autocomplete widget. + +The context argument is a simple namespace type: + +```python +@dataclass +class ContextArg: + request: HttpRequest + client_kwargs: django.http.QueryDict + # this is a redundant reference to request.GET +``` + +We may add additional attributes on this object in the future. + +If you're still using models but want different logic than the model-autocomplete, consider cracking open the `ModelAutocomplete` class and seeing how it works. It's probably easier to override its particular methods than to start from scratch and implement an efficient iterable that wraps querysets. + +## Tip: Custom Autocomplete base class + +If you have several autocompletes in your project, we recommend creating a base autocomplete class that extends `autocomplete.Autocomplete` and using that as your project-wide base class. Here you can customize translation strings, authentication-aware behaviour, min-search-length, max-results-count, etc. This way, you're also insulated from changes in our defaults. + +# Contributing + +To set up the development environment, follow these steps: - class Meta: - model = Person +```bash +# from root of project, +pip install -r requirements.txt +# running tests, +python manage.py test tests/ +# running app locally +python manage.py migrate +python manage.py runscript sample_app.dev_script +python manage.py runserver ``` diff --git a/autocomplete/__init__.py b/autocomplete/__init__.py index 887ea42..4fbca80 100644 --- a/autocomplete/__init__.py +++ b/autocomplete/__init__.py @@ -1,4 +1,8 @@ """ Allow HTMXAutoComplete to be imported from the module directly """ -from .autocomplete import HTMXAutoComplete + +from .core import Autocomplete, register +from .shortcuts import ModelAutocomplete +from .views import urls +from .widgets import AutocompleteWidget diff --git a/autocomplete/autocomplete.py b/autocomplete/autocomplete.py deleted file mode 100644 index d076309..0000000 --- a/autocomplete/autocomplete.py +++ /dev/null @@ -1,653 +0,0 @@ -""" -Django HTMX Autocomplete - -This file contains the main functionality of the component. -""" -import re - -from django.http import ( - HttpResponse, - HttpResponseBadRequest, - QueryDict, - HttpResponseNotFound, -) -from django.views import View -from django.template import loader -from django.urls import re_path -from django.core.exceptions import ImproperlyConfigured -from django.apps import apps -from django.db import models -from django.db.models.fields import CharField - -# The acceptable regex of the name attribute -NAME_PATTERN = r"^[a-zA-Z_$][0-9a-zA-Z_$]*$" - - -class HTMXAutoComplete(View): - """Abstract base class for autocomplete component instances - - Create new autocomplete components by extending this class and configuring - the required parameters. Many of the parameters are verified by the - `verify_config` class method and will throw errors if not properly - configured. This mechanism is meant only to assist developers and should - not be relied upon. - - Required attributes: - - name (str): The name attribute determines the name of the underlying - form element and should be unique in your application. It - will be used as the name of the url pattern and by default - is appended to the URL of the component's views. The - uniqueness of the name is not verified, but it must match - the regex pattern `NAME_PATTERN`. - - In simple cases we can link the component to a Django model by adding a - Meta subclass that defined the model related metadata. - - class Meta: - - model: A valid Django model or string ('app.model') - - Optionally the following attributes can also be defined: - - item_value: The column string or DeferredAttribute that will be - used as the item value. (Defaults to the PK) - - item_label: The column string or DeferredAttribute that will be - used as the item's label and used for searches. - Defaults to the first CharField, or the first field if - no CharFields exist. - - lookup: Lookup method used for search. This text will be added to - item_label property to perform searches. - Defaults to 'icontains'. - - For more fine-grained control over what items are available, you can - also override the `get_items` function. - - Note: You must either add a `Meta.model` attribute or override - `get_items`. If you do both the defined model is accessible via - `self.Meta.model`, and the `item_value` and `item_label` properties are - accessible as `self._item_value` and `self._item_label` respectively. - - Optional attributes: - - label (str or None): The label to use for the component (in HTML) - If defined it must be a string. - Defaults to None. - - placeholder (str or None): The placeholder text used on the component - Defaults to None. - - indicator (bool): If enabled will display a search indicator. - Defaults to False - - required (bool): If set the control is marked as required. - - no_result_text (str): The string displayed when no results are found. - Defaults to "No results found." - - max_results (int): The maximum number of search results to return to the - frontend, or None for all. - Defaults to None. - - custom_strings (dict): Dictionary containing custom strings to use for this - instance. Available keys are: - - no_results The string displayed when no - results are found. - - more_results Text to display when the results - are cut off due to max_results. - - available_results Text anounced to sceen readers - when results are available. If - max_results is set, the - more_results text is spoken - instead. - - minimum_search_length (int): The minimum search length to perform a search - and show the dropdown. - Defaults to 3. - - multiselect (boolean): Determines if a user can select multiple items - from the dropdown, or only 1. - Defaults to False. - - """ - - # Used for routing and input names. (abstract, must be unique) - name = None - - # If specified use this string instead of name for the route name - route_name = None - - # If specified use this string instead of route_name for the component ID in HTML - component_id = None - - # The component label passed to the template. - label = None - - # The placeholder text. (Typically something like "Type to search...") - placeholder = None - - # If set to True the HTML control will be marked as required - required = False - - # If true the component will be disabled - disabled = False - - # If enabled an indicator will be displayed while waiting for a network response - indicator = False - - # The minimum search length to perform a search and show the dropdown. - minimum_search_length = 3 - - # The maximum number of search results to return to the frontend, or None for all - max_results = None - - # Values in this set are stripped from any toggle operation. - strip_values = set(["undefined"]) - - # String overrides - custom_strings = dict() - - # If True will allow the user to select multiple items - multiselect = False - - # Used internally to reference the `value` field returned by `get_items` - _item_value = "value" - - # Used internally to reference the `label` field returned by `get_items` - _item_label = "label" - - # Used internally as the field lookup for model searches in `get_items` - _lookup = "icontains" - - @classmethod - def url_dispatcher(cls, route): - """Return the url pattern required for the component to function. - - Note: Calling this method on the base class returns an array of all - subclassed url dispatchers. - - Calling this method also verifies the configuration of the component. - - The following routes will be included in the pattern: - GET /{route}/{route_name}/items - GET /{route}/{route_name}/component - PUT /{route}/{route_name}/toggle - - Parameters: - route (str): The base URL to use when creating the route. - - Returns: - django.urls.URLPattern - """ - if cls == HTMXAutoComplete: - return [cls.url_dispatcher(route) for cls in cls.__subclasses__()] - - cls.verify_config() - prefix = f"{route}/{cls.get_route_name()}" - return re_path( - f"{prefix}/(?Pitems|component|toggle)$", - cls.as_view(), - name=cls.get_route_name(), - ) - - @classmethod - def _verify_label(cls): - """Test label validity""" - if ( - hasattr(cls, "label") - and cls.label is not None - and not isinstance(cls.label, str) - ): - raise ImproperlyConfigured("`label` must be a string.") - - @classmethod - def _verify_name(cls): - """Test that name is present and valid""" - if not isinstance(cls.name, str): - raise ImproperlyConfigured( - "`name` must a string and defined on the subclass." - ) - - if not re.match(NAME_PATTERN, cls.name): - raise ImproperlyConfigured( - f"`name` must match the following regex pattern: {NAME_PATTERN}" - ) - - @classmethod - def _verify_route_name(cls): - """Test that route_name is valid and unique""" - - if cls.route_name is not None and not re.match(NAME_PATTERN, cls.route_name): - raise ImproperlyConfigured( - f"`route_name` must match regex pattern: {NAME_PATTERN}" - ) - - if cls.get_route_name() in [ - cl.get_route_name() if cl is not cls else "" - for cl in HTMXAutoComplete.__subclasses__() - ]: - raise ImproperlyConfigured( - "Autocomplete components must have a unique route_name. The " - f"name {cls.get_route_name()}` is already in defined." - ) - - @classmethod - def _verify_component_id(cls): - """Test that component_id is valid and unique""" - - if cls.component_id is not None and not re.match( - NAME_PATTERN, cls.component_id - ): - raise ImproperlyConfigured( - f"`component_id` must match regex pattern: {NAME_PATTERN}" - ) - - if cls.get_component_id() in [ - cl.get_component_id() if cl is not cls else "" - for cl in HTMXAutoComplete.__subclasses__() - ]: - raise ImproperlyConfigured( - "Autocomplete components must have a unique id. The " - f"id {cls.get_component_id()}` is already in defined." - ) - - @classmethod - def _verify_model_or_get_items(cls): - """Test for meta options or get_items""" - if not hasattr(cls, "Meta") and HTMXAutoComplete.get_items == cls.get_items: - raise ImproperlyConfigured( - "You must either define a `Meta` class or override `get_items`" - ) - - @classmethod - def _get_and_verify_model(cls, meta): - """Test that model is defined and valid - Returns model object""" - - if not hasattr(meta, "model") or meta.model is None: - raise ImproperlyConfigured("You must set `model` in the `Meta` class.") - if isinstance(meta.model, str): - if not "." in meta.model: - raise ImproperlyConfigured( - "Meta.model should be an object or string in the format" - " app.model" - ) - try: - return apps.get_model(meta.model) - except Exception as exp: - raise ImproperlyConfigured(f"Error loading '{meta.model}'") from exp - elif not isinstance(meta.model, models.base.ModelBase): - raise ImproperlyConfigured( - 'Meta.model should be an object or string in the format "app.model"' - ) - return meta.model - - @classmethod - def _get_and_verify_item_value(cls, meta): - """Verify and get item_value""" - if hasattr(meta, "item_value"): - if not isinstance(meta.item_value, str) and not isinstance( - meta.item_value, models.query_utils.DeferredAttribute - ): - raise ImproperlyConfigured( - "If Meta.item_value is defined it must be a valid " - "column attribute" - ) - if not isinstance(meta.item_value, str): - return meta.item_value.field.name - - return meta.item_value - - return meta.model._meta.pk.name # pylint: disable=protected-access - - @classmethod - def _get_and_verify_item_label(cls, meta): - if hasattr(meta, "item_label"): - if not isinstance(meta.item_label, str) and not isinstance( - meta.item_label, models.query_utils.DeferredAttribute - ): - raise ImproperlyConfigured( - "If Meta.item_label is defined it must be a valid " - "column attribute" - ) - if not isinstance(meta.item_label, str): - return meta.item_label.field.name - return meta.item_label - - all_fields = meta.model._meta.fields # pylint: disable=protected-access - if len(all_fields) == 0: - raise ImproperlyConfigured("The chosen model has no fields.") - - char_fields = list(filter(lambda x: isinstance(x, CharField), all_fields)) - if len(char_fields) > 0: - return char_fields[0].name - - return all_fields[0].name - - @classmethod - def _get_and_verify_lookup(cls, meta): - """Get the lookup value""" - if hasattr(meta, "lookup"): - if not isinstance(meta.lookup, str): - raise ImproperlyConfigured( - "If Meta.lookup is defined it must be a field lookup string" - ) - - return meta.lookup - - return "icontains" - - @classmethod - def verify_config(cls): - """Verify that the component is correctly configured. - - Raises django.core.exceptions.ImproperlyConfigured - """ - - cls._verify_label() - cls._verify_name() - cls._verify_route_name() - cls._verify_component_id() - cls._verify_model_or_get_items() - - if hasattr(cls, "Meta"): - cls.Meta.model = cls._get_and_verify_model(cls.Meta) - cls._item_value = cls._get_and_verify_item_value(cls.Meta) - cls._item_label = cls._get_and_verify_item_label(cls.Meta) - cls._lookup = cls._get_and_verify_lookup(cls.Meta) - - @classmethod - def get_route_name(cls): - """Return the name to use for routes - - Returns: - str: Name to use for routes - """ - return cls.route_name if cls.route_name else cls.name - - @classmethod - def get_component_id(cls, override_id=None): - """Return the component id used by the template HTML to render the component - - Returns: - str: Component ID - """ - if override_id: - return override_id - return cls.component_id if cls.component_id else cls.get_route_name() - - def get_items(self, search=None, values=None): - """Get available items based on search or values. - - If search is specified, only items who's label contain the search - term will be included in the results. The label column is defined by - the Meta class's `item_label` attribute. - - If values is specified, only items who's value is contained in the - values array will be included in the results. The value column is - defined by the Meta class's `item_value` attribute. - - This method can be overridden to provide more advanced control over - how items are searched for or generated. In the case where the Meta - class is not specified at all, the overridden method is expected to - return an array of dictionaries where each item has the `label` and - value` keys defined. - - Parameters: - search (str): The search term - values (str[]): Array of values - - Returns: - array of dictionaries - """ - items = None - if search is not None: - search_dict = {f"{self._item_label}__{self._lookup}": search} - # pylint: disable=no-member - items = self.Meta.model.objects.filter(**search_dict) - - if values is not None: - search_dict = {self._item_value + "__in": values} - # pylint: disable=no-member - items = self.Meta.model.objects.filter(**search_dict) - - return items.values() if items is not None else [] - - def map_items(self, items, selected=None): - """Return an array of dictionaries suitable for use by the templates.""" - return list( - map( - lambda o: { - "label": str(o.get(self._item_label)), - "value": str(o.get(self._item_value)), - "selected": str(o.get(self._item_value)) in selected - if selected - else False, - }, - items, - ) - ) - - def item_values(self, items, only_selected=False): - """Returns a list of values. - - Typically used to set the form element's value. - - only_selected(bool): If set to True only the items with selected=True - will be used. - """ - return map( - lambda x: str(x.get("value")), - filter(lambda x: not only_selected or x.get("selected"), items), - ) - - def put(self, request, method): - """Handler for PUT /{route}/{name}/toggle - - A toggle request should include the item being toggled as well as the - currently selected items. This is because the response template will - include an updated list of selected items - and the state of what items - are selected is stored in the browser. The currently selected items are - also used to determine if the item is selected or not. - - Payload: - {name} (list): List of values currently "selected". - item (str): The value of the item being toggled. - - Returns: - HTMX responsible to update selected items displayed in the browser. - (autocomplete/item.html) - - Context: - name (str): Name of this component (for html elements) - route_name (str): Name to use to get routes - component_id (str): ID to use on component - multiselect (bool): Can the user select multiple items? - values (list): Updated list of values - item (dict): The item object being toggled - selected_items (dict[]): Updated list of selected items - swap_oob (bool): If True the returned item will be swapped out - of band. (Used if the user clicks the X on a chip, to - update the selected style of the option if it is currently - in the dropdown list) - - """ - data = QueryDict(request.body) - items_selected = data.getlist(self.name) - override_component_id = data.get("component_id", "") - component_name = data.get("name", self.name) - - if items_selected == [""]: - items_selected = [] - - items_selected = [a for a in items_selected if a not in self.strip_values] - - if method == "toggle": - item = data.get("item", None) - if item is None: - return HttpResponseBadRequest() - - items = self.map_items( - self.get_items(values=items_selected + [item]), items_selected - ) - - def sort_items(item): - try: - return items_selected.index(f"{item.get('value')}") - except ValueError: - return len(items_selected) - - items.sort(key=sort_items) - - target_item = next((x for x in items if x.get("value") == item), None) - - if target_item is None: - print("ERROR: Requested item to toggle not found.") - return HttpResponseNotFound() - - if target_item.get("selected"): - items.remove(target_item) - target_item["selected"] = False - else: - target_item["selected"] = True - - if not self.multiselect: - for item in items: - if item != target_item: - item["selected"] = False - - - template = loader.get_template("autocomplete/item.html") - return HttpResponse( - template.render( - { - "name": component_name, - "search": "", - "indicator": self.indicator, - "placeholder": self.placeholder, - "required": self.required, - "custom_strings": self.custom_strings, - "route_name": self.get_route_name(), - "component_id": self.get_component_id(override_component_id), - "multiselect": self.multiselect, - "values": list(self.item_values(items, True)), - "item_as_list": [target_item], - "item": target_item, - "toggle": items, - "swap_oob": data.get("remove", False), - } - ), - request, - ) - - return HttpResponseBadRequest() - - def get(self, request, method): - """Handler for GET /{route}/{name}/component and /{route}/{name}/items - - Common query parameters: - {name} (list): List of values currently "selected". - - GET /{route}/{name}/component - Renders the component itself. - - Returns: - HTMX responsible to render the root of the component. - (autocomplete/component.html) - - This can be used for initial rendering or to perform live - updates after events such as on `blur`. - Notice: The default implementation does not re-render on blur. - - Context: - name (str): Name of this component - route_name (str): Name to use to get routes - component_id (str): ID to use in HTML - label (str): The label of the control (or None) - placeholder (str): The placeholder text (or None) - multiselect (bool): Can the user selected multiple items? - values (list): List of selected values - selected_items (dict[]): List of selected items - - GET /{route}/{name}/items - Renders the list of items for the dropdown. - - Additional query parameters: - search (str): Search string used to filter the returned items - - Returns: - HTMX responsible to render the list of available items. - - Context: - name (str): Name of this component - route_name (str): Name to use to get routes - component_id (str): ID of the component - search (str): The search string (if any) - show (bool): Whether or not the dropdown should be shown - items (dict[]): List of items - """ - items_selected = request.GET.getlist(self.name) - if items_selected == [""]: - items_selected = [] - - override_component_id = request.GET.get("component_id", "") - component_name = request.GET.get("name", self.name) - - if method == "component": - template = loader.get_template("autocomplete/component.html") - selected_options = self.map_items(self.get_items(values=items_selected)) - - return HttpResponse( - template.render( - { - "name": component_name, - "disabled": self.disabled, - "required": self.required, - "indicator": self.indicator, - "route_name": self.get_route_name(), - "component_id": self.get_component_id(override_component_id), - "label": self.label, - "placeholder": self.placeholder, - "multiselect": self.multiselect, - "values": list(self.item_values(selected_options)), - "selected_items": list(selected_options), - "custom_strings": self.custom_strings, - }, - request, - ) - ) - - if method == "items": - template = loader.get_template("autocomplete/item_list.html") - search = request.GET.get("search", "") - show = len(search) >= self.minimum_search_length - items = ( - self.map_items(self.get_items(search), items_selected) if show else [] - ) - total_results = len(items) - if self.max_results is not None and len(items) > self.max_results: - items = items[: self.max_results] - - return HttpResponse( - template.render( - { - "name": component_name, - "required": self.required, - "placeholder": self.placeholder, - "indicator": self.indicator, - "custom_strings": self.custom_strings, - "multiselect": self.multiselect, - "route_name": self.get_route_name(), - "component_id": self.get_component_id(override_component_id), - "show": show, - "search": search, - "items": list(items), - "total_results": total_results, - }, - request, - ) - ) - - return HttpResponseBadRequest() diff --git a/autocomplete/core.py b/autocomplete/core.py new file mode 100644 index 0000000..6a6fcc6 --- /dev/null +++ b/autocomplete/core.py @@ -0,0 +1,115 @@ +from dataclasses import dataclass + +from django.conf import settings +from django.core.exceptions import PermissionDenied +from django.http import HttpRequest +from django.utils.translation import gettext_lazy as _ + +# This is the registry of registered autocomplete classes, +# i.e. the ones who respond to requests +_ac_registry = {} + + +AC_CLASS_CONFIGURABLE_VALUES = { + "disabled", + "no_result_text", + "narrow_search_text", + "minimum_search_length", + "max_results", + "component_prefix", + "placeholder", + "indicator", +} + + +def register(ac_class: type, route_name: str = None): + if not route_name: + route_name = ac_class.__name__ + + ac_class.validate() + + if route_name in _ac_registry: + raise ValueError(f"Autocomplete with name '{name}' is already registered.") + + ac_class.route_name = route_name + + _ac_registry[route_name] = ac_class + + return ac_class + + +class Autocomplete: + + no_result_text = _("No results found.") + narrow_search_text = _( + "Showing %(page_size)s of %(total)s items. Narrow your search for more results." + ) + type_at_least_n_characters = _("Type at least %(n)s characters") + minimum_search_length = 3 + max_results = 100 + component_prefix = "" + + @classmethod + def auth_check(cls, request): + """ + override to inspect request.user or whatever + raise a PermissionDenied or SuspiciousOperation exception if needed + """ + if ( + getattr(settings, "AUTOCOMPLETE_BLOCK_UNAUTHENTICATED", False) + and not request.user.is_authenticated + ): + raise PermissionDenied("Must be logged in to use autocomplete") + + pass + + @classmethod + def validate(cls): + if not hasattr(cls, "search_items"): + raise ValueError("You must implement a search_items method.") + + if not hasattr(cls, "get_items_from_keys"): + raise ValueError("You must implement a get_items_from_keys method.") + + @classmethod + def map_search_results(cls, items_iterable, selected_keys=None): + """ + This must return a list of dictionaries with the keys "key", "label", and "selected" + + By default, we already expect search_items to return iterable of the form [{"key": "value", "label": "label"}] + + You can override this to consume paginable querysets or whatever + """ + + return [ + { # this is the default mapping + "key": str(i["key"]), + "label": i["label"], + "selected": i["key"] in selected_keys or str(i["key"]) in selected_keys, + } + for i in items_iterable + ] + + @classmethod + def get_custom_strings(cls): + return { + "no_results": cls.no_result_text, + "more_results": cls.narrow_search_text, + "type_at_least_n_characters": cls.type_at_least_n_characters, + } + + @classmethod + def get_extra_text_input_hx_vals(cls): + """ + returns a dict of key/vals to go in the hx-vals attribute of the text input + - must not contain single quotes + - to support inline JS expressions, values are not quoted + """ + + return {} + + +@dataclass +class ContextArg: + request: HttpRequest + client_kwargs: dict diff --git a/autocomplete/locale/en/LC_MESSAGES/django.po b/autocomplete/locale/en/LC_MESSAGES/django.po index 74c3638..87e3cd1 100644 --- a/autocomplete/locale/en/LC_MESSAGES/django.po +++ b/autocomplete/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-02-08 20:30+0000\n" +"POT-Creation-Date: 2024-10-09 09:57-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,6 +18,22 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: core.py:43 +msgid "No results found." +msgstr "" + +#: core.py:45 +#, python-format +msgid "" +"Showing %(page_size)s of %(total)s items. Narrow your search for more " +"results." +msgstr "" + +#: core.py:47 +#, python-format +msgid "Type at least %(n)s characters" +msgstr "" + #: templates/autocomplete/strings/available_results.html:2 #, python-format msgid "%(count)s result available." @@ -34,26 +50,14 @@ msgstr "" msgid "%(item)s selected," msgstr "" -#: templates/autocomplete/strings/selected.html:2 -#, python-format -msgid "selected." -msgstr "" - -#: templates/autocomplete/strings/more_results.html:2 -#, python-format -msgid "Displaying maximum %(count)s out of %(total_results)s results." -msgid_plural "Displaying maximum %(count)s out of %(total_results)s results." -msgstr[0] "" -msgstr[1] "" - -#: templates/autocomplete/strings/multiselect.html:2 +#: templates/autocomplete/strings/multiselect.html:1 msgid "multiselect" msgstr "" -#: templates/autocomplete/strings/no_results.html:2 -msgid "No results found." -msgstr "" - #: templates/autocomplete/strings/nothing_selected.html:2 msgid "Nothing selected." msgstr "" + +#: templates/autocomplete/strings/selected.html:2 +msgid "selected." +msgstr "" diff --git a/autocomplete/locale/fr/LC_MESSAGES/django.po b/autocomplete/locale/fr/LC_MESSAGES/django.po index 5786af1..af946d9 100644 --- a/autocomplete/locale/fr/LC_MESSAGES/django.po +++ b/autocomplete/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-02-08 20:30+0000\n" +"POT-Creation-Date: 2024-10-09 09:57-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,6 +18,22 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" +#: core.py:43 +msgid "No results found." +msgstr "Aucun résultat trouvé." + +#: core.py:45 +#, python-format +msgid "" +"Showing %(page_size)s of %(total)s items. Narrow your search for more " +"results." +msgstr "Seulement %(page_size)s résultats de %(total)s sont affichés. Précisez votre requête pour plus de résultats." + +#: core.py:47 +#, python-format +msgid "Type at least %(n)s characters" +msgstr "Entrer au moins %(n)s caractères" + #: templates/autocomplete/strings/available_results.html:2 #, python-format msgid "%(count)s result available." @@ -27,33 +43,29 @@ msgstr[1] "%(count)s résultats disponibles" #: templates/autocomplete/strings/backspace_instruction.html:2 msgid "Press backspace to delete the last selected item." -msgstr "Appuyez sur retour arrière pour supprimer le dernier élément sélectionné." +msgstr "" +"Appuyez sur retour arrière pour supprimer le dernier élément sélectionné." #: templates/autocomplete/strings/item_selected.html:2 #, python-format msgid "%(item)s selected," msgstr "%(item)s sélectionné" -#: templates/autocomplete/strings/selected.html:2 -#, python-format -msgid "selected." -msgstr "sélectionné." - -#: templates/autocomplete/strings/more_results.html:2 -#, python-format -msgid "Displaying maximum %(count)s out of %(total_results)s results." -msgid_plural "Displaying maximum %(count)s out of %(total_results)s results." -msgstr[0] "Affichage maximum de %(count)s résultat sur %(total_results)s." -msgstr[1] "Affichage maximum de %(count)s résultats sur %(total_results)s." - -#: templates/autocomplete/strings/multiselect.html:2 +#: templates/autocomplete/strings/multiselect.html:1 msgid "multiselect" msgstr "sélection multiple" -#: templates/autocomplete/strings/no_results.html:2 -msgid "No results found." -msgstr "Aucun résultat trouvé." - #: templates/autocomplete/strings/nothing_selected.html:2 msgid "Nothing selected." msgstr "Rien de sélectionné." + +#: templates/autocomplete/strings/selected.html:2 +msgid "selected." +msgstr "sélectionné." + +#, python-format +#~ msgid "Displaying maximum %(count)s out of %(total_results)s results." +#~ msgid_plural "" +#~ "Displaying maximum %(count)s out of %(total_results)s results." +#~ msgstr[0] "Affichage maximum de %(count)s résultat sur %(total_results)s." +#~ msgstr[1] "Affichage maximum de %(count)s résultats sur %(total_results)s." diff --git a/autocomplete/shortcuts.py b/autocomplete/shortcuts.py new file mode 100644 index 0000000..70e71ac --- /dev/null +++ b/autocomplete/shortcuts.py @@ -0,0 +1,96 @@ +import operator +from functools import reduce + +from django.db.models import Q + +from .core import Autocomplete + + +class ModelAutocomplete(Autocomplete): + model = None + search_attrs = [] + + @classmethod + def get_search_attrs(cls): + if not cls.search_attrs: + raise ValueError("ModelAutocomplete must have search_attrs") + return cls.search_attrs + + @classmethod + def get_model(cls): + if not cls.model: + raise ValueError("ModelAutocomplete must have a model") + + return cls.model + + @classmethod + def get_queryset(cls): + return cls.get_model().objects.all() + + @classmethod + def get_label_for_record(cls, record): + return str(record) + + @classmethod + def get_query_filtered_queryset(cls, search, context): + base_qs = cls.get_queryset() + conditions = [ + Q(**{f"{attr}__icontains": search}) for attr in cls.get_search_attrs() + ] + condition_filter = reduce(operator.or_, conditions) + queryset = base_qs.filter(condition_filter) + return queryset + + @classmethod + def search_items(cls, search, context): + filtered_queryset = cls.get_query_filtered_queryset(search, context) + + items = QuerysetMappedIterable( + queryset=filtered_queryset, label_for_record=cls.get_label_for_record + ) + return items + + @classmethod + def get_items_from_keys(cls, keys, context): + queryset = cls.get_queryset() + results = queryset.filter(id__in=keys) + + return [{"key": person.id, "label": person.name} for person in results] + + +class QuerysetMappedIterable: + """ + We want to return an iterable of dicts rather than ORM records + + Using a list is inefficient for large datasets + But using something like a generator/map doesn't allow for slicing or len() + + This class wraps a queryset's slice/len methods + """ + + def __init__(self, queryset, label_for_record): + self.queryset = queryset + self.label_for_record = label_for_record + + def __getitem__(self, key): + # Handle both single index and slice objects + if isinstance(key, int): + records = [self.queryset[key]] + elif isinstance(key, slice): + records = self.queryset[key.start : key.stop : key.step] + else: + raise TypeError("Invalid argument type") + + mapped = [ + {"key": record.id, "label": self.label_for_record(record)} + for record in records + ] + + if isinstance(key, int): + return mapped[0] + + return mapped + + def __len__(self): + # Return the length of the sequence + return self.queryset.count() diff --git a/autocomplete/static/autocomplete/js/autocomplete.js b/autocomplete/static/autocomplete/js/autocomplete.js index 27de1a5..1750353 100644 --- a/autocomplete/static/autocomplete/js/autocomplete.js +++ b/autocomplete/static/autocomplete/js/autocomplete.js @@ -364,3 +364,62 @@ function phac_aspc_autocomplete_keydown_handler(event) { phac_aspc_autocomplete_clear_focus(container, true); return true; } + +class AbstractAutocompleteHelper { + /* + this is a helper class to manipulate autocomplete components + creating instances has zero side-effects + it's assumed you may instantiate the same component multiple times + */ + constructor(fieldName, componentPrefix="") { + this.fieldName = fieldName; + this.componentPrefix = componentPrefix; + } + getComponentId(){ + return this.componentPrefix + this.fieldName; + } + getContainer(){ + return document.getElementById(`${this.getComponentId()}__container`); + } + getInput(){ + return this.getContainer().querySelector(`#${this.getComponentId()}__textinput`); + } + getInputWrapper(){ + return this.getContainer().querySelector(`#${this.getComponentId()}`); + } + getResultItems(){ + return this.getContainer().querySelector(`#${this.getComponentId()}__items`); + } + getInfo(){ + return this.getContainer().querySelector(`#${this.getComponentId()}__info`); + } + getDataContainer(){ + return this.getContainer().querySelector(`#${this.getComponentId()}__data`); + } + // behavioral methods + clear(){ + this.getInput().value = ''; + this.getInputWrapper().innerHTML = ''; + this.getResultItems().innerHTML = ''; + this.getInfo().innerHTML = ''; + this.getDataContainer().removeAttribute('data-phac-aspc-autocomplete'); + } +} + +class SingleAutocompleteHelper extends AbstractAutocompleteHelper {} + +class MultiAutocompleteHelper extends AbstractAutocompleteHelper { + getSrDescription(){ + return this.getContainer().querySelector(`#${this.getComponentId()}__sr_description`); + } + + getChips(){ + return this.getContainer().querySelectorAll(`#${this.getComponentId()}_ac_container li.chip`); + } + + clear(){ + super.clear(); + this.getChips().forEach(chip => chip.remove()); + this.getSrDescription().innerHTML = ''; + } +} \ No newline at end of file diff --git a/autocomplete/templates/autocomplete/chip.html b/autocomplete/templates/autocomplete/chip.html index 6116f79..63edc77 100644 --- a/autocomplete/templates/autocomplete/chip.html +++ b/autocomplete/templates/autocomplete/chip.html @@ -1,3 +1,5 @@ +{% load autocomplete %} + {% comment %} This template renders a selected item or chip. {% endcomment %} @@ -6,9 +8,13 @@ {{ item.label }} {% if not disabled %} (function () { const { componentid, css, js, toggleurl } = document.currentScript.dataset; diff --git a/autocomplete/templates/autocomplete/item.html b/autocomplete/templates/autocomplete/item.html index e14bea0..c0134f6 100644 --- a/autocomplete/templates/autocomplete/item.html +++ b/autocomplete/templates/autocomplete/item.html @@ -2,17 +2,20 @@ {{ item.label|search_highlight:search }} diff --git a/autocomplete/templates/autocomplete/item_list.html b/autocomplete/templates/autocomplete/item_list.html index 1fc8b45..2405c12 100644 --- a/autocomplete/templates/autocomplete/item_list.html +++ b/autocomplete/templates/autocomplete/item_list.html @@ -1,6 +1,7 @@ {% load autocomplete %}
- {% use_string "no_results" custom_strings %} - + {% if query_too_short %} + + {% use_string "type_at_least_n_characters" custom_strings as str_template %} + {% substitute_string str_template n=minimum_search_length %} + + {% elif not items %} + + {% use_string "no_results" custom_strings %} + {% endif %} + {% if items|length != total_results %} -
- - {% use_string "more_results" custom_strings %} - -
+
+ + {% use_string "more_results" custom_strings as more_results_template %} + {% substitute_string more_results_template page_size=items|length total=total_results %} + +
{% endif %}
{% if items|length != total_results %} - {% use_string "more_results" custom_strings %} + {% use_string "more_results" custom_strings as more_results_template %} + {% substitute_string more_results_template page_size=items|length total=total_results %} {% else %} {% use_string "available_results" custom_strings %} {% endif %} diff --git a/autocomplete/templates/autocomplete/strings/more_results.html b/autocomplete/templates/autocomplete/strings/more_results.html deleted file mode 100644 index 93ee7e7..0000000 --- a/autocomplete/templates/autocomplete/strings/more_results.html +++ /dev/null @@ -1,6 +0,0 @@ -{% load i18n %} -{% blocktranslate trimmed with count=items|length count counter=items|length %} -Displaying maximum {{ count }} out of {{ total_results }} results. -{% plural %} -Displaying maximum {{ count }} out of {{ total_results }} results. -{% endblocktranslate %} \ No newline at end of file diff --git a/autocomplete/templates/autocomplete/strings/no_results.html b/autocomplete/templates/autocomplete/strings/no_results.html deleted file mode 100644 index 5a50381..0000000 --- a/autocomplete/templates/autocomplete/strings/no_results.html +++ /dev/null @@ -1,2 +0,0 @@ -{% load i18n %} -{% translate "No results found." %} diff --git a/autocomplete/templates/autocomplete/textinput.html b/autocomplete/templates/autocomplete/textinput.html index 14168b0..3b91e23 100644 --- a/autocomplete/templates/autocomplete/textinput.html +++ b/autocomplete/templates/autocomplete/textinput.html @@ -1,3 +1,5 @@ +{% load autocomplete %} + {% if not disabled or not multiselect %} {% endif %} {% if disabled %} diff --git a/autocomplete/templates/autocomplete/values.html b/autocomplete/templates/autocomplete/values.html index 206a8c0..4232a9a 100644 --- a/autocomplete/templates/autocomplete/values.html +++ b/autocomplete/templates/autocomplete/values.html @@ -1,8 +1,9 @@ {% comment %} Hidden input elements used to maintain the component's state {% endcomment %} {% comment %} and used when submitting forms {% endcomment %} +{% comment %} inputs are wrapped in spans so BS4 doesn't close them in tests {% endcomment %} {% for value in values %} - + {% endfor %} {% if required and values|length == 0 %} - + {% endif %} \ No newline at end of file diff --git a/autocomplete/templatetags/autocomplete.py b/autocomplete/templatetags/autocomplete.py index bba9a4b..534e9a2 100644 --- a/autocomplete/templatetags/autocomplete.py +++ b/autocomplete/templatetags/autocomplete.py @@ -1,14 +1,15 @@ """ Django template tags to facilitate rendering of the component """ + import hashlib +import json -from django import template -from django import urls -from django.utils.http import urlencode -from django.utils.html import escape, format_html +from django import template, urls from django.template import loader from django.template.defaultfilters import stringfilter +from django.utils.html import escape, format_html +from django.utils.http import urlencode from django.utils.safestring import mark_safe register = template.Library() @@ -60,6 +61,15 @@ def use_string(context, name, strings): ).render(context.flatten()) +@register.simple_tag +def substitute_string(template_str, **kwargs): + """ + Substitute the template string with the kwargs + """ + as_strings = {k: str(v) for k, v in kwargs.items()} + return template_str % as_strings + + @register.simple_tag def autocomplete(name, selected=None): """ @@ -94,6 +104,14 @@ def autocomplete(name, selected=None): ) +@register.filter +def js_boolean(value): + """ + Convert the value to a javascript boolean + """ + return "true" if value else "false" + + @register.simple_tag def autocomplete_head(bootstrap=False): """ @@ -134,3 +152,110 @@ def autocomplete_scripts(context, bootstrap=False, htmx=False, htmx_csrf=False): "htmx_csrf": htmx_csrf, } ) + + +@register.simple_tag +def value_if_truthy(test, value, default=""): + """ + Return the value if it is truthy, otherwise return the default + """ + return value if test else default + + +@register.simple_tag(takes_context=True) +def base_configurable_values_hx_params(context): + + field_name = context.get("field_name") + required = context.get("required") + disabled = context.get("disabled") + placeholder = context.get("placeholder") + multiselect = context.get("multiselect") + + hx_params = f"{field_name},field_name,item,component_prefix" + + if required: + hx_params += ",required" + + if disabled: + hx_params += ",disabled" + + if placeholder: + hx_params += ",placeholder" + + if multiselect: + hx_params += ",multiselect" + + return mark_safe(hx_params) + + +@register.simple_tag(takes_context=True) +def base_configurable_hx_vals(context): + """ + json-like format + must be wrapped in curly braces + """ + + field_name = context.get("field_name") + required = context.get("required") + disabled = context.get("disabled") + placeholder = context.get("placeholder") + multiselect = context.get("multiselect") + component_prefix = context.get("component_prefix") + + props = { + "field_name": escape(field_name), + "component_prefix": component_prefix, + } + + if required: + props["required"] = bool(required) + + if disabled: + props["disabled"] = bool(disabled) + + if multiselect: + props["multiselect"] = bool(multiselect) + + if placeholder: + props["placeholder"] = escape(placeholder) + + hx_vals = json.dumps(props).replace("{", "").replace("}", "") + + return mark_safe(hx_vals) + + +def stringify_extra_hx_vals(extra_hx_vals_dict): + if any("'" in val for val in extra_hx_vals_dict.values()): + raise ValueError( + "Extra hx vals cannot contain single quotes, consider backticks for JS expressions or escaping double-quotes" + ) + + return ",".join([f' "{key}": {val}' for key, val in extra_hx_vals_dict.items()]) + + +@register.simple_tag(takes_context=True) +def text_input_hx_vals(context): + """ + items has augments hx-vals, + - it adds JS value of the search input + - users can add more values in their class + """ + + base_hx_vals_str = base_configurable_hx_vals(context) + + component_id_escape = escape(context.get("component_id")) + + val = ( + "js:{" + f"{base_hx_vals_str}," + f'search: document.getElementById("{component_id_escape}__textinput").value' + ) + + extra_hx_vals = context.get("ac_class").get_extra_text_input_hx_vals() + if extra_hx_vals: + extra_hx_val_str = stringify_extra_hx_vals(extra_hx_vals) + val = f"{val}, {extra_hx_val_str}" + + val = val + "}" + + return mark_safe(val) diff --git a/autocomplete/test_autocomplete.py b/autocomplete/test_autocomplete.py deleted file mode 100644 index f0cd145..0000000 --- a/autocomplete/test_autocomplete.py +++ /dev/null @@ -1,25 +0,0 @@ -""" -Autocomplete tests -""" -import unittest -from .autocomplete import HTMXAutoComplete - - -class TestAutocomplete(unittest.TestCase): - """Test case for Autocomplete""" - - def test_classes_generate_routes(self): - """Creating Autocomplete subclasses automatically generates routes""" - class Test(HTMXAutoComplete): # pylint: disable=unused-variable - """test case 1""" - name = "test1" - def get_items(self, search=None, values=None): - return [] - class Test2(HTMXAutoComplete): # pylint: disable=unused-variable - """test case 2""" - name = "test" - def get_items(self, search=None, values=None): - return [] - - urls = HTMXAutoComplete.url_dispatcher('test') - self.assertEqual(len(urls), 2) diff --git a/autocomplete/views.py b/autocomplete/views.py new file mode 100644 index 0000000..f8295d1 --- /dev/null +++ b/autocomplete/views.py @@ -0,0 +1,189 @@ +from django.http import HttpResponseBadRequest +from django.shortcuts import render +from django.urls import path +from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ +from django.views import View + +from .core import AC_CLASS_CONFIGURABLE_VALUES, ContextArg, _ac_registry + + +class AutocompleteBaseView(View): + @cached_property + def ac_class(self): + ac_name = self.kwargs["ac_name"] + + try: + return _ac_registry[ac_name] + + except KeyError as e: + raise ValueError(f"No registered autocomplete with name {ac_name}") from e + + def dispatch(self, request, *args, **kwargs): + self.ac_class.auth_check(request) + + return super().dispatch(request, *args, **kwargs) + + @cached_property + def request_dict(self): + # convert the request's QueryDict into a regular dict + return self.request.GET.dict() + + def get_field_name(self): + return self.request_dict["field_name"] + + def get_component_id(self): + prefix = self.get_configurable_value("component_prefix") + + return prefix + self.get_field_name() + + def get_configurable_value(self, key): + if key in self.request_dict: + return self.request.GET.get(key) + + if key in AC_CLASS_CONFIGURABLE_VALUES and hasattr(self.ac_class, key): + return getattr(self.ac_class, key) + + return None + + def get_template_context(self): + # many things will come from the request + # others will be picked up from the AC class + + return { + "route_name": self.ac_class.route_name, + "ac_class": self.ac_class, + "field_name": self.get_field_name(), + "component_id": self.get_component_id(), + "required": bool(self.get_configurable_value("required")), + "placeholder": self.get_configurable_value("placeholder"), + "indicator": self.get_configurable_value("indicator"), + "custom_strings": self.ac_class.get_custom_strings(), + "multiselect": bool(self.get_configurable_value("multiselect")), + "component_prefix": self.get_configurable_value("component_prefix"), + "disabled": bool(self.get_configurable_value("disabled")), + } + + +class ToggleView(AutocompleteBaseView): + def get(self, request, *args, **kwargs): + field_name = self.request_dict["field_name"] + + current_items = self.request.GET.getlist(field_name) + if current_items == ["undefined"] or current_items == [""]: + current_items = [] + + key_to_toggle = request.GET.get("item") + + if key_to_toggle is None: + return HttpResponseBadRequest() + + new_selected_keys = list(current_items) + + is_multi = self.get_configurable_value("multiselect") + + if is_multi: + if key_to_toggle in current_items: + new_selected_keys.remove(key_to_toggle) + else: + new_selected_keys.append(key_to_toggle) + else: + if new_selected_keys == []: + new_selected_keys = [key_to_toggle] + else: + new_selected_keys = [] + + keys_to_fetch = set(new_selected_keys).union({key_to_toggle}) + + context_obj = ContextArg(request=request, client_kwargs=request.GET) + all_values = self.ac_class.get_items_from_keys(keys_to_fetch, context_obj) + + items = self.ac_class.map_search_results(all_values, new_selected_keys) + + # OOB is used if the user clicks the X on a chip, + # to update the selected style of the option + # if it is currently in the dropdown list + swap_oob = request.GET.get("remove", False) + + target_item = next((x for x in items if x["key"] == key_to_toggle), None) + + new_items = [x for x in items if x["key"] in new_selected_keys] + + def sort_items(item): + try: + return current_items.index(f"{item['key']}") + except ValueError: + return len(new_items) + + new_items = sorted(new_items, key=sort_items) + + if target_item is None: + raise ValueError("Requested item to toggle not found.") + + return render( + request, + "autocomplete/item.html", + { + **self.get_template_context(), + "search": "", + "values": new_selected_keys, + "item_as_list": [target_item], + "item": target_item, + "toggle": new_items, + "swap_oob": swap_oob, + }, + ) + + +class ItemsView(AutocompleteBaseView): + def get(self, request, *args, **kwargs): + context_obj = ContextArg(request=request, client_kwargs=request.GET) + + search_query = request.GET.get("search", "") + search_results = self.ac_class.search_items( + # or whatever + search_query, + context_obj, + ) + + field_name = self.get_configurable_value("field_name") + selected_keys = request.GET.getlist(field_name) + + query_too_short = len(search_query) < self.ac_class.minimum_search_length + + if query_too_short: + total_results = 0 + search_results = [] + + else: + total_results = len(search_results) + if len(search_results) > self.ac_class.max_results: + search_results = search_results[: self.ac_class.max_results] + + items = self.ac_class.map_search_results(search_results, selected_keys) + + # render items ... + return render( + request, + "autocomplete/item_list.html", + { + # note: name -> field_name + **self.get_template_context(), + "show": not (query_too_short), + "query_too_short": query_too_short, + "search": search_query, + "items": items, + "total_results": total_results, + "minimum_search_length": self.ac_class.minimum_search_length, + }, + ) + + +urls = ( + [ + path("autocomplete//items", ItemsView.as_view(), name="items"), + path("autocomplete//toggle", ToggleView.as_view(), name="toggle"), + ], + "autocomplete", + "autocomplete", +) diff --git a/autocomplete/widgets.py b/autocomplete/widgets.py index 8efb3f9..afee677 100644 --- a/autocomplete/widgets.py +++ b/autocomplete/widgets.py @@ -1,90 +1,40 @@ """ This file enables the component to be used like other Django widgets """ + from django.forms import Widget -from .autocomplete import HTMXAutoComplete - - -class Autocomplete(Widget): - """ - Django forms compatible autocomplete widget - - Parameters: - - name (str): The name of the component (must be unique) - attrs (dict): disabled and required attributes are supported - use_ac Autocomplete: Optional. Use existing autocomplete class - options (dict): See [autocomplete.py](../autocomplete.py) for more info - label (str) - component_id (str) Defaults to None - indicator (bool) Defaults to false - placeholder (str) - no_result_text (str) Defaults to "No results found." - narrow_search_text (str) Defaults to - "Narrow your search for more results". - max_results (int) Defaults to None - minimum_search_length (int) Defaults to 3 - multiselect (str) Defaults to False - model (str) - item_value (str) - item_label (str) - lookup (str) - get_items (func) - - """ +from .core import AC_CLASS_CONFIGURABLE_VALUES, Autocomplete - template_name = "autocomplete/component.html" - def __init__( - self, - name="", - use_ac=None, - options=None, - attrs=None, - ): - opts = options or {} +class AutocompleteWidget(Widget): + template_name = "autocomplete/component.html" + configurable_values = [ + "indicator", + "multiselect", + "label", + "component_prefix", + # the below are also configurable from the AC class + "placeholder", + ] + + def __init__(self, ac_class, attrs=None, options=None): + self.ac_class = ac_class super().__init__(attrs) - if use_ac is None: - config = { - "name": name, - "disabled": attrs.get("disabled", False) if attrs else False, - "required": attrs.get("required", False) if attrs else False, - "indicator": opts.get("indicator", None), - "route_name": opts.get("route_name", None), - "component_id": opts.get("component_id", None), - "label": opts.get("label", None), - "placeholder": opts.get("placeholder", None), - "no_result_text": opts.get("no_result_text", "No results found."), - "narrow_search_text": opts.get( - "narrow_search_text", "Narrow your search for more results." - ), - "max_results": opts.get("max_results", None), - "minimum_search_length": opts.get("minimum_search_length", 3), - "multiselect": opts.get("multiselect", False), - } - - if model := opts.get("model", None): - mdl_config = {"model": model} - if item_value := opts.get("item_value", None): - mdl_config["item_value"] = item_value - if item_label := opts.get("item_label", None): - mdl_config["item_label"] = item_label - if lookup := opts.get("lookup", None): - mdl_config["lookup"] = lookup - - config["Meta"] = type("Meta", (object,), mdl_config) - else: - config["get_items"] = opts.get("get_items", HTMXAutoComplete.get_items) + if not options: + options = {} - self.a_c = type(f"HtmxAc__{name}", (HTMXAutoComplete,), config) - else: - self.a_c = use_ac + self.config = {} + for k, v in options.items(): + if k in self.configurable_values: + self.config[k] = v + else: + raise ValueError(f"Invalid option {k}") def value_from_datadict(self, data, files, name): - if self.a_c.multiselect: + if self.is_multi: try: # classic POSTs go though django's QueryDict structure # which has a getlist method @@ -103,34 +53,54 @@ def value_omitted_from_data(self, data, files, name): # never known if the value is actually omitted. return [] + def get_component_id(self, field_name): + prefix = self.get_configurable_value("component_prefix") + + return prefix + field_name + + def get_configurable_value(self, key): + if key in self.config: + return self.config.get(key) + + if key in AC_CLASS_CONFIGURABLE_VALUES and hasattr(self.ac_class, key): + return getattr(self.ac_class, key) + + return None + + @property + def is_multi(self): + return self.get_configurable_value("multiselect") + def get_context(self, name, value, attrs): context = super().get_context(name, value, attrs) - items_selected = ( - [] if value is None else [value] if not isinstance(value, list) else value - ) - selected_options = self.a_c.map_items( - self.a_c, - self.a_c.get_items(self.a_c, values=[str(x) for x in items_selected]), - ) - - context["name"] = attrs.get("name", self.attrs.get("name", self.a_c.name)) - - context["disabled"] = attrs.get("disabled", self.attrs.get("disabled", False)) - context["required"] = attrs.get("required", self.attrs.get("required", False)) - - context["indicator"] = self.a_c.indicator - context["route_name"] = self.a_c.get_route_name() - context["component_id"] = self.a_c.get_component_id( - attrs.get("component_id", self.attrs.get("component_id", None)) - ) - context["label"] = self.a_c.label - context["placeholder"] = self.a_c.placeholder - context["multiselect"] = self.a_c.multiselect - context["values"] = list(self.a_c.item_values(self.a_c, selected_options)) - context["selected_items"] = list(selected_options) - - self.a_c.required = context["required"] - self.a_c.disabled = context["disabled"] + proper_attrs = self.build_attrs(self.attrs, attrs) + + if value is None: + selected_options = [] + else: + if self.is_multi: + selected_options = self.ac_class.get_items_from_keys(value, None) + else: + selected_options = self.ac_class.get_items_from_keys([value], None) + + context["ac_class"] = self.ac_class + context["field_name"] = name + context["id"] = attrs.get("id", self.attrs.get("id", None)) + context["route_name"] = self.ac_class.route_name + + context["disabled"] = proper_attrs.get("disabled", False) + context["required"] = proper_attrs.get("required", False) + + context["indicator"] = self.get_configurable_value("indicator") + context["multiselect"] = self.is_multi + + context["label"] = self.get_configurable_value("label") + context["placeholder"] = self.get_configurable_value("placeholder") + # context["values"] = list(self.a_c.item_values(self.a_c, selected_options)) + context["values"] = [x["key"] for x in selected_options] + context["selected_items"] = selected_options + context["component_prefix"] = self.get_configurable_value("component_prefix") + context["component_id"] = self.get_component_id(name) return context diff --git a/tests/app/manage.py b/manage.py similarity index 69% rename from tests/app/manage.py rename to manage.py index 64555bd..72ba5d7 100755 --- a/tests/app/manage.py +++ b/manage.py @@ -6,7 +6,7 @@ def main(): """Run administrative tasks.""" - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sample_app.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: @@ -19,12 +19,4 @@ def main(): if __name__ == "__main__": - sys.path.append( - os.path.realpath( - os.path.join( - os.path.dirname(os.path.abspath(__file__)), - '..', '..' - ) - ) - ) main() diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..2d75e9c --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +DJANGO_SETTINGS_MODULE= sample_app.settings +addopts = -p no:warnings -v -s +asyncio_mode = auto diff --git a/requirements.txt b/requirements.txt index 8d82744..1917e6c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,12 @@ +beautifulsoup4==4.12.3 black==22.12.0 +django-extensions==3.2.3 +djlint==1.35.2 Django==4.1.4 -Faker==16.6.1 +coverage==7.1.0 +ipython==8.10.0 pylint==2.15.9 pylint-django==2.5.3 +pytest==7.2.1 +pytest-django==4.8.0 +factory-boy===3.2.1 diff --git a/tests/app/ac_test/__init__.py b/sample_app/__init__.py similarity index 100% rename from tests/app/ac_test/__init__.py rename to sample_app/__init__.py diff --git a/tests/app/ac_test/admin.py b/sample_app/admin.py similarity index 100% rename from tests/app/ac_test/admin.py rename to sample_app/admin.py diff --git a/tests/app/ac_test/apps.py b/sample_app/apps.py similarity index 61% rename from tests/app/ac_test/apps.py rename to sample_app/apps.py index d3a8339..1153f0e 100644 --- a/tests/app/ac_test/apps.py +++ b/sample_app/apps.py @@ -1,6 +1,6 @@ from django.apps import AppConfig -class AcTestConfig(AppConfig): +class SampleAppConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" - name = "ac_test" + name = "sample_app" diff --git a/tests/app/app/asgi.py b/sample_app/asgi.py similarity index 81% rename from tests/app/app/asgi.py rename to sample_app/asgi.py index f29ff9b..e2c5836 100644 --- a/tests/app/app/asgi.py +++ b/sample_app/asgi.py @@ -11,6 +11,6 @@ from django.core.asgi import get_asgi_application -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sample_app.settings") application = get_asgi_application() diff --git a/sample_app/dev_script.py b/sample_app/dev_script.py new file mode 100644 index 0000000..4088d77 --- /dev/null +++ b/sample_app/dev_script.py @@ -0,0 +1,21 @@ +import random + +from django.db import transaction + +from sample_app.models import Person, PersonFactory, Team, TeamFactory + + +@transaction.atomic +def run(): + Team.objects.all().delete() + Person.objects.all().delete() + + people = PersonFactory.create_batch(200) + teams = TeamFactory.create_batch(40) + + for team in teams: + team_members = random.sample(people, random.randint(0, 8)) + if team_members: + team.members.set(team_members) + team.team_lead = random.choice(team_members) + team.save() diff --git a/tests/app/ac_test/migrations/0001_initial.py b/sample_app/migrations/0001_initial.py similarity index 63% rename from tests/app/ac_test/migrations/0001_initial.py rename to sample_app/migrations/0001_initial.py index d1c2e35..1ffbb6a 100644 --- a/tests/app/ac_test/migrations/0001_initial.py +++ b/sample_app/migrations/0001_initial.py @@ -1,16 +1,7 @@ -# Generated by Django 4.1.4 on 2023-02-07 16:53 +# Generated by Django 4.1.4 on 2024-09-20 01:19 from django.db import migrations, models - -from faker import Faker - -def generate_names(apps, schema_editor): - faker = Faker() - Person = apps.get_model("ac_test", "Person") - for x in range(0, 1000): - p = Person() - p.name = faker.name() - p.save() +import django.db.models.deletion class Migration(migrations.Migration): @@ -48,8 +39,21 @@ class Migration(migrations.Migration): ), ), ("name", models.CharField(max_length=60)), - ("members", models.ManyToManyField(to="ac_test.person")), + ( + "members", + models.ManyToManyField( + related_name="teams", to="sample_app.person" + ), + ), + ( + "team_lead", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="lead_teams", + to="sample_app.person", + ), + ), ], ), - migrations.RunPython(generate_names), ] diff --git a/tests/app/ac_test/migrations/__init__.py b/sample_app/migrations/__init__.py similarity index 100% rename from tests/app/ac_test/migrations/__init__.py rename to sample_app/migrations/__init__.py diff --git a/sample_app/models.py b/sample_app/models.py new file mode 100644 index 0000000..34510e1 --- /dev/null +++ b/sample_app/models.py @@ -0,0 +1,31 @@ +import factory +from django.db import models + + +class Person(models.Model): + name = models.CharField(max_length=60) + + def __str__(self): + return self.name + + +class Team(models.Model): + name = models.CharField(max_length=60) + team_lead = models.ForeignKey( + Person, null=True, on_delete=models.SET_NULL, related_name="lead_teams" + ) + members = models.ManyToManyField(Person, related_name="teams") + + +class PersonFactory(factory.django.DjangoModelFactory): + class Meta: + model = Person + + name = factory.Faker("name") + + +class TeamFactory(factory.django.DjangoModelFactory): + class Meta: + model = Team + + name = factory.Faker("name") diff --git a/tests/app/app/settings.py b/sample_app/settings.py similarity index 94% rename from tests/app/app/settings.py rename to sample_app/settings.py index dfa60af..3b39662 100644 --- a/tests/app/app/settings.py +++ b/sample_app/settings.py @@ -38,7 +38,8 @@ "django.contrib.messages", "django.contrib.staticfiles", "autocomplete", - "ac_test", + "sample_app", + "django_extensions", ] MIDDLEWARE = [ @@ -51,7 +52,7 @@ "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = "app.urls" +ROOT_URLCONF = "sample_app.urls" TEMPLATES = [ { @@ -69,7 +70,7 @@ }, ] -WSGI_APPLICATION = "app.wsgi.application" +WSGI_APPLICATION = "sample_app.wsgi.application" # Database @@ -123,3 +124,6 @@ # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + + +TEST_RUNNER = "tests.pytest_test_runner.PytestTestRunner" diff --git a/sample_app/templates/_generic_form.html b/sample_app/templates/_generic_form.html new file mode 100644 index 0000000..0ba3209 --- /dev/null +++ b/sample_app/templates/_generic_form.html @@ -0,0 +1,5 @@ +
+ {% csrf_token %} + {{ form.as_p }} + +
\ No newline at end of file diff --git a/sample_app/templates/base.html b/sample_app/templates/base.html new file mode 100644 index 0000000..e80110d --- /dev/null +++ b/sample_app/templates/base.html @@ -0,0 +1,35 @@ +{% load static %} + + + + + + Bootstrap demo + + + + + + {% block content %} + {% endblock %} + + + + + diff --git a/sample_app/templates/edit_team.html b/sample_app/templates/edit_team.html new file mode 100644 index 0000000..7e5665b --- /dev/null +++ b/sample_app/templates/edit_team.html @@ -0,0 +1,26 @@ +{% extends 'base.html' %} + +{% load autocomplete %} +{% block content %} +
+
+ {% csrf_token %} +
+ {{ form.team_lead.label_tag }} +
+ {{ form.team_lead }} +
+
+
+ {{ form.members.label_tag }} +
+ {{ form.members }} +
+
+ +
+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/tests/app/ac_test/templates/index.html b/sample_app/templates/index.html similarity index 72% rename from tests/app/ac_test/templates/index.html rename to sample_app/templates/index.html index ab685ab..006180f 100644 --- a/tests/app/ac_test/templates/index.html +++ b/sample_app/templates/index.html @@ -1,24 +1,6 @@ +{% extends 'base.html' %} {% load autocomplete %} -{% load static %} - - - - - - Bootstrap demo - - - - - +{% block content %}

Autocomplete HTMX Test cases

@@ -114,16 +96,4 @@

Using template tags

- - - - - +{% endblock content %} diff --git a/tests/app/ac_test/tests.py b/sample_app/tests.py similarity index 100% rename from tests/app/ac_test/tests.py rename to sample_app/tests.py diff --git a/tests/app/app/urls.py b/sample_app/urls.py similarity index 64% rename from tests/app/app/urls.py rename to sample_app/urls.py index 16dc89a..148791e 100644 --- a/tests/app/app/urls.py +++ b/sample_app/urls.py @@ -7,20 +7,26 @@ 1. Add an import: from my_app import views 2. Add a URL to urlpatterns: path('', views.home, name='home') Class-based views - 1. Add an import: from other_app.views import Home + 1. Add an import: from other_sample_app.views import Home 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') Including another URLconf 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ + from django.contrib import admin from django.urls import path -from autocomplete import HTMXAutoComplete -from ac_test import views +from autocomplete import urls as autocomplete_urls +from sample_app import views urlpatterns = [ path("admin/", admin.site.urls), - path("", views.index, name="index"), - *HTMXAutoComplete.url_dispatcher("ac"), + path("teams//edit/", views.edit_team, name="edit_team"), + path( + "teams//edit/with_prefix/", + views.example_with_prefix, + name="edit_team", + ), + path("ac/", autocomplete_urls), ] diff --git a/sample_app/views.py b/sample_app/views.py new file mode 100644 index 0000000..acd50f2 --- /dev/null +++ b/sample_app/views.py @@ -0,0 +1,132 @@ +from django import forms +from django.http import HttpResponse, HttpResponseRedirect +from django.shortcuts import render +from django.template import loader + +from autocomplete import Autocomplete, AutocompleteWidget, register + +from .models import Person, Team + + +@register +class PersonAutocomplete(Autocomplete): + @classmethod + def search_items(cls, search, context): + qs = Person.objects.filter(name__icontains=search) + + return [{"key": person.id, "label": person.name} for person in qs] + + @classmethod + def get_items_from_keys(cls, keys, context): + qs = Person.objects.filter(id__in=keys) + return [{"key": person.id, "label": person.name} for person in qs] + + +class TeamForm(forms.ModelForm): + # this form isn't meant to work for saving, we're using different "names" + class Meta: + model = Team + fields = ["team_lead", "members"] + widgets = { + "team_lead": AutocompleteWidget( + ac_class=PersonAutocomplete, + ), + "members": AutocompleteWidget( + ac_class=PersonAutocomplete, + options={"multiselect": True}, + ), + } + + +def edit_team(request, team_id=None): + team = Team.objects.get(id=team_id) + + form = TeamForm(instance=team, data=request.POST or None) + + if request.POST and form.is_valid(): + form.save() + return HttpResponseRedirect(request.path) + + return render(request, "edit_team.html", {"form": form}) + + +@register +class CustomPersonAutocomplete(PersonAutocomplete): + no_result_text = "Keine resultate" + narrow_search_text = "NARROW IT DOWN" + max_results = 1 + placeholder = "Select team lead!" + + +@register +class CustomPersonAutocomplete2(PersonAutocomplete): + """ + this AC is meant to be used in a form with a 'team_lead' field + + the extra-hx vals will submit an additional field when searching + + this extra param will be used by the search method + to filter out the team_lead from the members + """ + + @classmethod + def get_extra_text_input_hx_vals(cls): + # single quotes not allowed here, backticks used as 2nd level quotes + return { + "related_team_lead": 'document.querySelector(`[name="team_lead"]`)?.value || "" ', + # "literal": "foo", # note that this causes 'ReferenceError: foo is not defined' + "literal": '"foo"', # wrapping in double quotes works + } + + @classmethod + def search_items(cls, search, context): + qs = Person.objects.filter(name__icontains=search) + + related_team_lead = context.client_kwargs.get("related_team_lead", None) + if related_team_lead: + qs = qs.exclude(id=related_team_lead) + + return [{"key": person.id, "label": person.name} for person in qs] + + +class TeamForm2(forms.ModelForm): + # this form isn't meant to work for saving, we're using different "names" + class Meta: + model = Team + fields = ["team_lead", "members"] + # fields = ["team_lead"] + widgets = { + "team_lead": AutocompleteWidget( + ac_class=CustomPersonAutocomplete, + options={ + "component_prefix": "team_lead_prefix", + # "placeholder": "Select team lead", + }, + attrs={ + "required": False, + }, + ), + "members": AutocompleteWidget( + ac_class=CustomPersonAutocomplete2, + options={"multiselect": True}, + ), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["team_lead"].required = False + # self.fields["members"].required = False + # self.fields["members"].disabled = True + # self.fields["team_lead"].disabled = True + + +def example_with_prefix(request, team_id=None): + team = Team.objects.get(id=team_id) + + form = TeamForm2(instance=team, data=request.POST or None) + + if request.POST and form.is_valid(): + form.save() + return HttpResponseRedirect(request.path) + + return render(request, "edit_team.html", {"form": form}) diff --git a/tests/app/app/wsgi.py b/sample_app/wsgi.py similarity index 81% rename from tests/app/app/wsgi.py rename to sample_app/wsgi.py index 03af5d2..e80400c 100644 --- a/tests/app/app/wsgi.py +++ b/sample_app/wsgi.py @@ -11,6 +11,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sample_app.settings") application = get_wsgi_application() diff --git a/tests/app/app/__init__.py b/tests/__init__.py similarity index 100% rename from tests/app/app/__init__.py rename to tests/__init__.py diff --git a/tests/app/ac_test/ac_controls.py b/tests/app/ac_test/ac_controls.py deleted file mode 100644 index 8ab3ed3..0000000 --- a/tests/app/ac_test/ac_controls.py +++ /dev/null @@ -1,108 +0,0 @@ -from autocomplete import HTMXAutoComplete - -from .models import Person - -data = [ - {"value": "1", "label": "Newsome Instruments Ltd"}, - {"value": "2", "label": "Dixon Refrigeration Ltd"}, - {"value": "3", "label": "Quirke Skips Ltd"}, - {"value": "4", "label": "Talisman Costume Hire Ltd"}, - {"value": "5", "label": "Hallworth Carpenters Ltd"}, - {"value": "6", "label": "Peel Building Materials Ltd"}, - {"value": "7", "label": "Ainsley Meat Wholesalers Ltd"}, - {"value": "8", "label": "Earl Recreational Vehicles Ltd"}, - {"value": "9", "label": "Crocket Footwear Ltd"}, - {"value": "10", "label": "Ashton Plant Hire Ltd"}, - {"value": "11", "label": "Valley Home Services Ltd"}, - {"value": "12", "label": "Keaton Engineering Ltd"}, - {"value": "13", "label": "Edwards Developments Ltd"}, - {"value": "14", "label": "Leary Car Repairs Ltd"}, - {"value": "15", "label": "Yeoman Fitness Products Ltd"}, - {"value": "16", "label": "Crocket Office Furniture Ltd"}, - {"value": "17", "label": "Walcott Beauty Treatments Ltd"}, - {"value": "18", "label": "Malloney Tractors Ltd"}, - {"value": "19", "label": "Sterling Fruit Importers Ltd"}, - {"value": "20", "label": "Tattershall Car Repairs Ltd"}, - {"value": "21", "label": "Lyons Reproductions Ltd"}, - {"value": "22", "label": "Elgar Freight Ltd"}, - {"value": "23", "label": "West Point Luxury Cars Ltd"}, - {"value": "24", "label": "Summit Printing Ltd"}, - {"value": "25", "label": "Alexander Lab Equipment Ltd"}, - {"value": "26", "label": "Goodacre Camping Supplies Ltd"}, - {"value": "27", "label": "Hunt Footwear Ltd"}, - {"value": "28", "label": "Eckard Printing Ltd"}, - {"value": "29", "label": "Fisher Design Ltd"}, - {"value": "30", "label": "Grady,Fine Accountancy Services Ltd"}, - {"value": "31", "label": "Reeve Locksmiths Ltd"}, - {"value": "32", "label": "Eagle Skips Ltd"}, - {"value": "33", "label": "Adkinson Demolition Ltd"}, - {"value": "34", "label": "Pendrick Skips Ltd"}, - {"value": "35", "label": "Jarvis Agency Ltd"}, - {"value": "36", "label": "East View Automations Ltd"}, - {"value": "37", "label": "Mack Fabrics Ltd"}, - {"value": "38", "label": "Bainbridge Construction Ltd"}, - {"value": "39", "label": "North Side Camping Supplies Ltd"}, - {"value": "40", "label": "Gatley Furniture Ltd"}, - {"value": "41", "label": "Addler Disposal Ltd"}, - {"value": "42", "label": "Cromwell Cleaners Ltd"}, - {"value": "43", "label": "Craft Building Materials Ltd"}, - {"value": "44", "label": "Knowles Showrooms Ltd"}, - {"value": "45", "label": "Allen Luxury Cars Ltd"}, - {"value": "46", "label": "Thistlemoor Builders Ltd"}, - {"value": "47", "label": "Valley Kitchens Ltd"}, - {"value": "48", "label": "Dobson Cosmetics Ltd"}, - {"value": "49", "label": "West View Hire Cars Ltd"}, - {"value": "50", "label": "Bentley Meat Wholesalers Ltd"}, -] - - -class GetItemsAutoComplete(HTMXAutoComplete): - name = "getitems" - minimum_search_length = 0 - - def get_items(self, search=None, values=None): - if values: - return list(filter(lambda x: x.get("value") in values, data)) - - if search: - return list(filter(lambda x: x.get("label").startswith(search), data)) - - if search == "": - return list(data) - - return [] - - -class ModelAutoComplete(HTMXAutoComplete): - name = "model" - minimum_search_length = 0 - - class Meta: - model = Person - - -class GetItemsMultiAutoComplete(HTMXAutoComplete): - name = "getitems_multi" - multiselect = True - minimum_search_length = 0 - - def get_items(self, search=None, values=None): - if values: - return list(filter(lambda x: x.get("value") in values, data)) - - if search: - return list(filter(lambda x: x.get("label").startswith(search), data)) - - if search == "": - return list(data) - - return [] - - -class ModelMultiAutoComplete(HTMXAutoComplete): - name = "model_multi" - multiselect = True - minimum_search_length = 0 - - class Meta: - model = Person diff --git a/tests/app/ac_test/forms.py b/tests/app/ac_test/forms.py deleted file mode 100644 index f8c67be..0000000 --- a/tests/app/ac_test/forms.py +++ /dev/null @@ -1,113 +0,0 @@ -""" -Form objects used to test the autocomplete's widget interface -""" -from django import forms -from autocomplete import widgets - -from .models import Person, Team -from .ac_controls import data - - -class SingleFormGetItem(forms.Form): - """Form used for single select using get_items""" - - @staticmethod - def get_items(search=None, values=None): - """Example function used to provide list of options to widget - - Args: - search (str, optional): Search string. Defaults to None. - values (str[], optional): Values to return. Defaults to None. - - Returns: - dict[]: List of dictionaries with value and label keys. - """ - if values: - return list(filter(lambda x: x.get("value") in values, data)) - - return list( - filter(lambda x: x.get("label").lower().startswith(search.lower()), data) - ) - - name = forms.CharField() - company = forms.CharField( - widget=widgets.Autocomplete( - name="company", options=dict(get_items=get_items, minimum_search_length=0) - ) - ) - - -class SingleFormModel(forms.ModelForm): - """Single select example form using a model""" - - class Meta: - """Meta class that configures the form""" - - model = Team - fields = ["name", "members"] - field_classes = { - "members": forms.ModelChoiceField - } - widgets = { - "members": widgets.Autocomplete( - name="members", options=dict(model=Person, minimum_search_length=0) - ) - } - - -class MultipleFormGetItem(forms.Form): - """Form used for multiple select using get_items""" - - @staticmethod - def get_items(search=None, values=None): - """Example function used to provide list of options to widget - - Args: - search (str, optional): Search string. Defaults to None. - values (str[], optional): Values to return. Defaults to None. - - Returns: - dict[]: List of dictionaries with value and label keys. - """ - items = None - if search is not None: - items = Person.objects.filter(name__startswith=search) - - if values is not None: - items = Person.objects.filter(id__in=values) - - return [{"label": x.name, "value": x.id} for x in items] - - name = forms.CharField() - members = forms.CharField( - widget=widgets.Autocomplete( - name="members", - options=dict( - multiselect=True, - get_items=get_items, - route_name="multi_members", - minimum_search_length=0, - ), - ) - ) - - -class MultipleFormModel(forms.ModelForm): - """Multiple select example form using a model""" - - class Meta: - """Meta class that configures the form""" - - model = Team - fields = ["name", "members"] - widgets = { - "members": widgets.Autocomplete( - name="members", - options=dict( - multiselect=True, - model=Person, - route_name="multi_model_members", - minimum_search_length=0, - ), - ) - } diff --git a/tests/app/ac_test/models.py b/tests/app/ac_test/models.py deleted file mode 100644 index 7f779c5..0000000 --- a/tests/app/ac_test/models.py +++ /dev/null @@ -1,13 +0,0 @@ -from django.db import models - -class Person(models.Model): - name = models.CharField(max_length=60) - - def __str__(self): - return self.name - - -class Team(models.Model): - name = models.CharField(max_length=60) - members = models.ManyToManyField(Person) - diff --git a/tests/app/ac_test/views.py b/tests/app/ac_test/views.py deleted file mode 100644 index 671d943..0000000 --- a/tests/app/ac_test/views.py +++ /dev/null @@ -1,31 +0,0 @@ -from django.template import loader -from django.http import HttpResponse - -from . import ac_controls -from .forms import ( - SingleFormGetItem, - SingleFormModel, - MultipleFormGetItem, - MultipleFormModel, -) - - -def index(request): - template = loader.get_template("index.html") - single_form_get_item = SingleFormGetItem({"name": "Team Pickle", "company": [2]}) - single_form_model = SingleFormModel({"name": "Team Pickles", "members": [1]}) - multi_form_get_item = MultipleFormGetItem( - {"name": "Team Pickle", "members": [1, 2, 3, 21]} - ) - multi_form_model = MultipleFormModel(request.POST or None) - return HttpResponse( - template.render( - { - "single_form_model": single_form_model, - "single_form_get_item": single_form_get_item, - "multi_form_get_item": multi_form_get_item, - "multi_form_model": multi_form_model, - }, - request, - ) - ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8ee8e3c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,33 @@ +import pytest +from django.db import transaction + +from autocomplete import Autocomplete, ModelAutocomplete, register +from sample_app.models import Person + + +@pytest.fixture(autouse=True) +def enable_db_access_for_all_tests(db): + """ + without this, tests (including old-style) have to explicitly declare db as a dependency + https://pytest-django.readthedocs.io/en/latest/faq.html#how-can-i-give-database-access-to-all-my-tests-without-the-django-db-marker + """ + pass + + +@pytest.fixture(scope="session") +def globally_scoped_fixture_helper(django_db_setup, django_db_blocker): + with django_db_blocker.unblock(): + # Wrap in try + atomic block to do non crashing rollback + # This means we don't have to re-create a test DB each time + try: + with transaction.atomic(): + yield + raise Exception + except Exception: + pass + + +@register +class PersonAC(ModelAutocomplete): + model = Person + search_attrs = ["name"] diff --git a/tests/pytest_test_runner.py b/tests/pytest_test_runner.py new file mode 100644 index 0000000..f5ba686 --- /dev/null +++ b/tests/pytest_test_runner.py @@ -0,0 +1,50 @@ +class PytestTestRunner: + """Runs pytest to discover and run tests.""" + + @classmethod + def add_arguments(cls, parser): + parser.set_defaults(keepdb=True) + + # remaps to the -k cli arg of pytest, useful for test selection + parser.add_argument( + "-s", + "--select", + help="remaps to -k test-selection argument in pytest", + ) + + def __init__( + self, + verbosity=1, + failfast=False, + keepdb=True, + select=None, + **kwargs, + ): + self.verbosity = verbosity + self.failfast = failfast + self.keepdb = keepdb + self.select = select + + def run_tests(self, test_labels): + """Run pytest and return the exitcode. + + It translates some of Django's test command option to pytest's. + """ + import pytest + + argv = [] + if self.select is not None: + argv.append(f"-k {self.select}") + if self.verbosity == 0: + argv.append("--quiet") + if self.verbosity == 2: + argv.append("--verbose") + if self.verbosity == 3: + argv.append("-vv") + if self.failfast: + argv.append("--exitfirst") + if self.keepdb: + argv.append("--reuse-db") + + argv.extend(test_labels) + return pytest.main(argv) diff --git a/tests/test_auth_check.py b/tests/test_auth_check.py new file mode 100644 index 0000000..3683284 --- /dev/null +++ b/tests/test_auth_check.py @@ -0,0 +1,74 @@ +from django.contrib.auth.models import User +from django.http import QueryDict +from django.test import override_settings +from django.urls import reverse + +from sample_app.models import PersonFactory +from tests.conftest import PersonAC + + +def urls(): + p = PersonFactory() + + toggle_url = reverse( + "autocomplete:toggle", + kwargs={ + "ac_name": "PersonAC", + }, + ) + + qs_dict = QueryDict(mutable=True) + qs_dict.update( + { + "field_name": "myfield_name", + "item": p.id, + }, + ) + toggle_url = f"{toggle_url}?{qs_dict.urlencode()}" + + items_url = reverse( + "autocomplete:items", + kwargs={ + "ac_name": "PersonAC", + }, + ) + + qs_dict = QueryDict(mutable=True) + qs_dict.update( + { + "field_name": "myfield_name", + "component_prefix": "component_name", + "search": "abcd", + }, + ) + items_url = f"{items_url}?{qs_dict.urlencode()}" + + return toggle_url, items_url + + +def test_auth_check_blocks_unauthenticated(client): + + with override_settings(AUTOCOMPLETE_BLOCK_UNAUTHENTICATED=True): + response = client.get(urls()[0]) + assert response.status_code == 403 + response = client.get(urls()[1]) + assert response.status_code == 403 + + +def test_disabled_auth_check_allows_unauthenticated(client): + with override_settings(AUTOCOMPLETE_BLOCK_UNAUTHENTICATED=False): + response = client.get(urls()[0]) + assert response.status_code == 200 + response = client.get(urls()[1]) + assert response.status_code == 200 + + +def test_auth_check_enabled_allows_authenticated(client): + u = User.objects.create(username="a") + client.force_login(u) + + with override_settings(AUTOCOMPLETE_BLOCK_UNAUTHENTICATED=True): + response = client.get(urls()[0]) + assert response.status_code == 200 + response = client.get(urls()[1]) + assert response.status_code == 200 diff --git a/tests/test_items.py b/tests/test_items.py new file mode 100644 index 0000000..c9702b6 --- /dev/null +++ b/tests/test_items.py @@ -0,0 +1,287 @@ +import json + +from django.http import HttpRequest, QueryDict +from django.urls import reverse + +from autocomplete.core import Autocomplete, register +from sample_app.models import Person, PersonFactory, Team, TeamFactory +from tests.conftest import PersonAC + +from .utils_for_test import get_soup + + +def test_items_response_non_multi(client): + + people = PersonFactory.create_batch(5) + searchable_person = PersonFactory(name="abcdefg") + searchable_person2 = PersonFactory(name="abcdxyz") + + base_url = reverse("autocomplete:items", kwargs={"ac_name": "PersonAC"}) + qs_dict = QueryDict(mutable=True) + qs_dict.update( + { + "field_name": "myfield_name", + "component_prefix": "component_name", + "search": "abcd", + } + ) + full_url = f"{base_url}?{qs_dict.urlencode()}" + + response = client.get(full_url) + assert response.status_code == 200 + + soup = get_soup(response) + + listbox = soup.select_one("div[role='listbox']") + assert listbox.attrs["id"] == "component_namemyfield_name__items" + assert "aria-multiselectable" not in listbox.attrs + + # abcd should match two people, + options = listbox.select("a") + assert len(options) == 2 + assert "abcdefg" in options[0].get_text() + assert "abcdxyz" in options[1].get_text() + + assert json.loads(options[0].attrs["hx-vals"]) == { + "field_name": "myfield_name", + "component_prefix": "component_name", + "item": str(searchable_person.id), + } + assert json.loads(options[1].attrs["hx-vals"]) == { + "field_name": "myfield_name", + "component_prefix": "component_name", + "item": str(searchable_person2.id), + } + + highlight_span = listbox.select_one("span.highlight") + assert highlight_span.get_text() == "abcd" + + assert options[0].attrs["hx-get"] == reverse( + "autocomplete:toggle", kwargs={"ac_name": "PersonAC"} + ) + assert ( + options[0].attrs["hx-params"] == "myfield_name,field_name,item,component_prefix" + ) + assert options[0].attrs["hx-include"] == "#component_namemyfield_name" + assert "component_namemyfield_name__item__" in options[0].attrs["id"] + assert not options[1].attrs["id"] == options[0].attrs["id"] + + +def test_items_response_multi(client): + + class PersonAC2(Autocomplete): + # registry is not cleared between tests, must use unique names + + @classmethod + def search_items(cls, search, context): + qs = Person.objects.filter(name__icontains=search) + + return [{"key": person.id, "label": person.name} for person in qs] + + @classmethod + def get_items_from_keys(cls, keys, context): + return Person.objects.filter(id__in=keys) + + register(PersonAC2) + + people = PersonFactory.create_batch(5) + searchable_person = PersonFactory(name="abcdefg") + searchable_person2 = PersonFactory(name="abcdxyz") + + base_url = reverse("autocomplete:items", kwargs={"ac_name": "PersonAC2"}) + qs_dict = QueryDict(mutable=True) + qs_dict.update( + { + "field_name": "myfield_name", + "component_prefix": "component_name", + "search": "abcd", + "multiselect": True, + } + ) + full_url = f"{base_url}?{qs_dict.urlencode()}" + + response = client.get(full_url) + assert response.status_code == 200 + + soup = get_soup(response) + + listbox = soup.select_one("div[role='listbox']") + assert listbox.attrs["id"] == "component_namemyfield_name__items" + assert listbox.attrs["aria-multiselectable"] == "true" + assert listbox.attrs["aria-description"] == "multiselect" + + # abcd should match two people, + options = listbox.select("a") + assert len(options) == 2 + assert "abcdefg" in options[0].get_text() + assert "abcdxyz" in options[1].get_text() + + assert json.loads(options[0].attrs["hx-vals"]) == { + "field_name": "myfield_name", + "component_prefix": "component_name", + "item": str(searchable_person.id), + "multiselect": True, + } + assert json.loads(options[1].attrs["hx-vals"]) == { + "field_name": "myfield_name", + "component_prefix": "component_name", + "item": str(searchable_person2.id), + "multiselect": True, + } + + highlight_span = listbox.select_one("span.highlight") + assert highlight_span.get_text() == "abcd" + + assert options[0].attrs["hx-get"] == reverse( + "autocomplete:toggle", kwargs={"ac_name": "PersonAC2"} + ) + assert ( + options[0].attrs["hx-params"] + == "myfield_name,field_name,item,component_prefix,multiselect" + ) + assert options[0].attrs["hx-include"] == "#component_namemyfield_name" + assert "component_namemyfield_name__item__" in options[0].attrs["id"] + assert not options[1].attrs["id"] == options[0].attrs["id"] + + # now add abcdefg as member and try again, + qs_dict.setlist("myfield_name", [searchable_person.id]) + full_url = f"{base_url}?{qs_dict.urlencode()}" + + response = client.get(full_url) + assert response.status_code == 200 + + soup = get_soup(response) + + options = soup.select("div[role='listbox'] > a") + assert len(options) == 2 + assert "abcdefg" in options[0].get_text() + assert "abcdxyz" in options[1].get_text() + + assert options[0].attrs["aria-selected"] == "true" + + +def test_custom_options(client): + + people = PersonFactory.create_batch(5) + searchable_person = PersonFactory(name="abcdefg") + searchable_person2 = PersonFactory(name="abcdxyz") + + base_url = reverse("autocomplete:items", kwargs={"ac_name": "PersonAC"}) + + qs_dict = QueryDict(mutable=True) + qs_dict.update( + { + "field_name": "myfield_name", + "component_prefix": "component_name", + "search": "abcd", + "placeholder": "my placeholder", + "required": True, + "disabled": True, + } + ) + full_url = f"{base_url}?{qs_dict.urlencode()}" + + response = client.get(full_url) + assert response.status_code == 200 + + soup = get_soup(response) + + result = soup.select_one("div[role='listbox'] a[role='option']") + hx_params = result.attrs["hx-params"].split(",") + assert "required" in hx_params + assert "disabled" in hx_params + assert "placeholder" in hx_params + + hx_vals = json.loads(result.attrs["hx-vals"]) + assert hx_vals["required"] + assert hx_vals["disabled"] + assert hx_vals["placeholder"] == "my placeholder" + + +def test_limit_results(client): + people = PersonFactory.create_batch(5) + searchable_person = PersonFactory(name="abcdefg") + searchable_person2 = PersonFactory(name="abcdxyz") + searchable_person3 = PersonFactory(name="abcdxyz2") + + @register + class LimitedPersonAC(PersonAC): + max_results = 2 + narrow_search_text = "NARROW IT DOWN" + + base_url = reverse("autocomplete:items", kwargs={"ac_name": "LimitedPersonAC"}) + qs_dict = QueryDict(mutable=True) + qs_dict.update( + { + "field_name": "myfield", + "search": "abcd", + } + ) + full_url = f"{base_url}?{qs_dict.urlencode()}" + + response = client.get(full_url) + assert response.status_code == 200 + + soup = get_soup(response) + listbox = soup.select_one("div[role='listbox']") + results = listbox.select("a") + assert len(results) == 2 + + more_results = listbox.select_one("div.more-results") + assert "NARROW IT DOWN" in more_results.get_text() + + +def test_no_results(client): + @register + class NoResultsPersonAC(PersonAC): + max_results = 2 + no_result_text = "NO RESULTS" + + base_url = reverse("autocomplete:items", kwargs={"ac_name": "NoResultsPersonAC"}) + qs_dict = QueryDict(mutable=True) + qs_dict.update( + { + "field_name": "myfield", + "search": "abcd", + } + ) + full_url = f"{base_url}?{qs_dict.urlencode()}" + response = client.get(full_url) + assert response.status_code == 200 + + soup = get_soup(response) + listbox = soup.select_one("div[role='listbox']") + results = listbox.select("a") + assert len(results) == 0 + items = listbox.select("span.item") + assert len(items) == 1 + assert "NO RESULTS" in items[0].get_text() + + +def test_query_too_short(client): + base_url = reverse("autocomplete:items", kwargs={"ac_name": "PersonAC"}) + + qs_dict = QueryDict(mutable=True) + qs_dict.update( + { + "field_name": "myfield_name", + "component_prefix": "component_name", + "search": "s", + "placeholder": "my placeholder", + "required": True, + "disabled": True, + } + ) + full_url = f"{base_url}?{qs_dict.urlencode()}" + + response = client.get(full_url) + assert response.status_code == 200 + + soup = get_soup(response) + + listbox = soup.select_one("div[role='listbox']") + results = listbox.select("a") + assert len(results) == 0 + items = listbox.select("span.item") + assert len(items) == 1 + assert "Type at least 3 characters" in items[0].get_text() diff --git a/tests/test_model_ac.py b/tests/test_model_ac.py new file mode 100644 index 0000000..13644e1 --- /dev/null +++ b/tests/test_model_ac.py @@ -0,0 +1,52 @@ +import pytest +from django import forms +from django.template import Context, Template, loader +from django.urls import reverse + +from autocomplete import Autocomplete, AutocompleteWidget, ModelAutocomplete, register +from autocomplete.core import ContextArg +from sample_app.models import Person, PersonFactory, Team, TeamFactory + +from .utils_for_test import soup_from_str + + +class PersonModelAC(ModelAutocomplete): + model = Person + search_attrs = ["name"] + + +def test_model_ac_search(): + p1 = PersonFactory(name="John1") + p2 = PersonFactory(name="John2") + p3 = PersonFactory(name="John3") + p4 = PersonFactory(name="Jones") + + results = PersonModelAC.search_items("Joh", ContextArg(None, None)) + + assert len(results) == 3 + assert list(results) == [ + {"label": "John1", "key": p1.id}, + {"label": "John2", "key": p2.id}, + {"label": "John3", "key": p3.id}, + ] + + assert PersonModelAC.get_items_from_keys([p1.id, p2.id], {}) == [ + {"label": "John1", "key": p1.id}, + {"label": "John2", "key": p2.id}, + ] + + +def test_model_ac_search_max_results(): + class PersonModelAC(ModelAutocomplete): + model = Person + search_attrs = ["name"] + max_results = 2 + + p1 = PersonFactory(name="John1") + p2 = PersonFactory(name="John2") + p3 = PersonFactory(name="John3") + p4 = PersonFactory(name="Jones") + + results = PersonModelAC.search_items("Joh", ContextArg(None, None)) + # should still contain 3 results, the view is responsible for truncating + assert len(results) == 3 diff --git a/tests/test_toggle.py b/tests/test_toggle.py new file mode 100644 index 0000000..80b3482 --- /dev/null +++ b/tests/test_toggle.py @@ -0,0 +1,249 @@ +import json + +from django.http import QueryDict +from django.urls import reverse + +from autocomplete.core import Autocomplete, register +from sample_app.models import Person, PersonFactory, Team, TeamFactory + +from .utils_for_test import get_soup + + +class PersonAC3(Autocomplete): + + @classmethod + def search_items(cls, search, context): + qs = Person.objects.filter(name__icontains=search) + + return [{"key": person.id, "label": person.name} for person in qs] + + @classmethod + def get_items_from_keys(cls, keys, context): + qs = Person.objects.filter(id__in=keys) + return [{"key": person.id, "label": person.name} for person in qs] + + +register(PersonAC3) + + +def test_toggle_response_select_from_empty_non_multi(client): + """ + first, try adding selectin a person when none is selected + """ + people = PersonFactory.create_batch(5) + to_add = PersonFactory() + + base_url = reverse("autocomplete:toggle", kwargs={"ac_name": "PersonAC3"}) + qs_dict = QueryDict(mutable=True) + qs_dict.update( + { + "field_name": "myfield_name", + "component_prefix": "component_name", + "item": to_add.id, + } + ) + + response = client.get(f"{base_url}?{qs_dict.urlencode()}") + assert response.status_code == 200 + + soup = get_soup(response) + + # 1. The element we are toggling (not sure why this is even included) + toggled_option = soup.select("a[role='option']") + assert len(toggled_option) == 1 + + # 2. The hidden inputs that actually hold the form values + hidden_inputs_container = soup.select_one("div#component_namemyfield_name") + assert hidden_inputs_container.attrs["hx-swap-oob"] == "true" + hidden_inputs = hidden_inputs_container.select( + "input[type='hidden'][name='myfield_name']" + ) + assert len(hidden_inputs) == 1 + assert hidden_inputs[0].attrs["value"] == str(to_add.id) + + # 3. The autocomplete input + # this component should have mostly been tested in the items test + text_input = soup.select_one("input#component_namemyfield_name__textinput") + assert text_input.attrs["hx-vals"] + assert "multiselect" not in text_input.attrs["hx-vals"] + + assert text_input.attrs["value"] == to_add.name + + # 4. The "data" span, not sure how this is used + data_span = soup.select_one("span#component_namemyfield_name__data") + assert data_span.attrs["data-phac-aspc-autocomplete"] == str(to_add.name) + + # 5. some script tag to keep events updated, + script_tag = soup.select_one( + "script[data-componentid='component_namemyfield_name']" + ) + assert "phac_aspc_autocomplete_trigger_change" in script_tag.get_text() + + +def test_toggle_response_unselect_non_multi(client): + """ + try toggling off a person that was selected + """ + people = PersonFactory.create_batch(5) + to_remove = PersonFactory() + + base_url = reverse("autocomplete:toggle", kwargs={"ac_name": "PersonAC3"}) + + # next, try removign a person from a list with one person + qs_dict = QueryDict(mutable=True) + qs_dict.update( + { + "field_name": "myfield_name", + "myfield_name": to_remove.id, + "component_prefix": "component_name", + "item": to_remove.id, + } + ) + + response = client.get(f"{base_url}?{qs_dict.urlencode()}") + assert response.status_code == 200 + + soup = get_soup(response) + + +def test_toggle_multi(client): + people = PersonFactory.create_batch(5) + p1 = PersonFactory() + p2 = PersonFactory() + + base_url = reverse("autocomplete:toggle", kwargs={"ac_name": "PersonAC3"}) + qs_dict = QueryDict(mutable=True) + qs_dict.update( + { + "field_name": "myfield_name", + "component_prefix": "component_name", + "item": p1.id, + "multiselect": True, + } + ) + + response = client.get(f"{base_url}?{qs_dict.urlencode()}") + assert response.status_code == 200 + + soup = get_soup(response) + + # 1. The element we are toggling + toggled_option = soup.select("a[role='option']") + assert len(toggled_option) == 1 + + # 2. hidden inputs + hidden_inputs_container = soup.select_one("div#component_namemyfield_name") + assert hidden_inputs_container.attrs["hx-swap-oob"] == "true" + hidden_inputs = hidden_inputs_container.select( + "input[type='hidden'][name='myfield_name']" + ) + + # 3. The autocomplete input + assert soup.select_one("ul#component_namemyfield_name_ac_container") + chips = soup.select("ul#component_namemyfield_name_ac_container > li.chip") + chip_buttons = soup.select( + "ul#component_namemyfield_name_ac_container > li.chip > a" + ) + for chip_a in chip_buttons: + assert "multiselect" in chip_a.attrs["hx-params"] + assert "multiselect" in chip_a.attrs["hx-vals"] + + input_li = soup.select( + "ul#component_namemyfield_name_ac_container > li.input:not(.chip)" + ) + assert len(chips) == 1 + assert len(input_li) == 1 + + # 4. The "info", I think this is a11y stuff + info_text = soup.select_one("div#component_namemyfield_name__info").get_text() + assert p1.name in info_text + + # 5. screen reader description, kinda redundant with 4 + sr_description = soup.select_one( + "div#component_namemyfield_name__sr_description" + ).get_text() + assert p1.name in sr_description + + assert len(hidden_inputs) == 1 + values = {int(i.attrs["value"]) for i in hidden_inputs} + assert values == {p1.id} + + qs_dict = QueryDict(mutable=True) + qs_dict.update( + { + "field_name": "myfield_name", + "component_prefix": "component_name", + "multiselect": True, + "myfield_name": p1.id, + "item": p2.id, + } + ) + response = client.get(f"{base_url}?{qs_dict.urlencode()}") + assert response.status_code == 200 + + soup = get_soup(response) + + # hidden inputs: + hidden_inputs = soup.select( + "div#component_namemyfield_name input[type='hidden'][name='myfield_name']" + ) + assert len(hidden_inputs) == 2 + values = {int(i.attrs["value"]) for i in hidden_inputs} + assert values == {p1.id, p2.id} + + +def test_toggle_multi_untoggle(client): + people = PersonFactory.create_batch(5) + p1 = PersonFactory() + p2 = PersonFactory() + p3 = PersonFactory() + + base_url = reverse("autocomplete:toggle", kwargs={"ac_name": "PersonAC3"}) + + # untoggle from a list of 3 + qs_dict = QueryDict(mutable=True) + qs_dict.update( + { + "field_name": "myfield_name", + "component_prefix": "component_name", + "item": p1.id, + "multiselect": True, + } + ) + qs_dict.setlist("myfield_name", [p1.id, p2.id, p3.id]) + + response = client.get(f"{base_url}?{qs_dict.urlencode()}") + assert response.status_code == 200 + + soup = get_soup(response) + + # 1. The element we are toggling + toggled_option = soup.select("a[role='option']") + assert len(toggled_option) == 1 + + # 2. hidden inputs + hidden_inputs = soup.select( + "div#component_namemyfield_name input[type='hidden'][name='myfield_name']" + ) + assert len(hidden_inputs) == 2 + values = {int(i.attrs["value"]) for i in hidden_inputs} + assert values == {p2.id, p3.id} + + qs_dict = QueryDict(mutable=True) + qs_dict.update( + { + "field_name": "myfield_name", + "component_prefix": "component_name", + "item": p1.id, + "multiselect": True, + "myfield_name": p1.id, + } + ) + resp = client.get(f"{base_url}?{qs_dict.urlencode()}") + assert resp.status_code == 200 + soup = get_soup(resp) + + hidden_inputs = soup.select( + "div#component_namemyfield_name input[type='hidden'][name='myfield_name']" + ) + assert len(hidden_inputs) == 0 diff --git a/tests/test_widget_render.py b/tests/test_widget_render.py new file mode 100644 index 0000000..fd02e2d --- /dev/null +++ b/tests/test_widget_render.py @@ -0,0 +1,415 @@ +import pytest +from django import forms +from django.template import Context, Template, loader +from django.urls import reverse + +from autocomplete import Autocomplete, AutocompleteWidget, register +from sample_app.models import Person, PersonFactory, Team, TeamFactory + +from .utils_for_test import soup_from_str + + +@register +class PersonAC4(Autocomplete): + + @classmethod + def search_items(cls, search, context): + qs = Person.objects.filter(name__icontains=search) + + return [{"key": person.id, "label": person.name} for person in qs] + + @classmethod + def get_items_from_keys(cls, keys, context): + qs = Person.objects.filter(id__in=keys) + return [{"key": person.id, "label": person.name} for person in qs] + + +single_form_template = Template( + """ + {{ form.as_p }} + """ +) + + +def render_template(template, ctx_dict): + context = Context(ctx_dict) + return template.render(context) + + +def test_render_widget_in_form_empty(): + class FormWithSingle(forms.ModelForm): + class Meta: + model = Team + fields = ["team_lead"] + + widgets = { + "team_lead": AutocompleteWidget( + ac_class=PersonAC4, + ) + } + + create_form = FormWithSingle() + + rendered = render_template(single_form_template, {"form": create_form}) + + soup = soup_from_str(rendered) + + # check the form label works + label = soup.select_one("label[for='id_team_lead']") + assert label.text == "Team lead:" + + # check focus ring, + focus_ring = soup.select_one("div.phac_aspc_form_autocomplete_focus_ring") + assert focus_ring + + component_container = focus_ring.select_one( + "div.phac_aspc_form_autocomplete#team_lead__container" + ) + assert component_container + + # 1. hidden input are in # + # it starts out empty without even a name + component = component_container.select_one("#team_lead") + inputs = component.select('span > input[type="hidden"]') + assert len(inputs) == 1 + assert inputs[0].attrs["name"] == "team_lead" + assert "value" not in inputs[0].attrs + + # 2. script + scripts = soup.select("script") + assert len(scripts) == 1 + assert scripts[0].attrs["data-componentid"] == "team_lead" + assert scripts[0].attrs["data-toggleurl"] == reverse( + "autocomplete:toggle", args=["PersonAC4"] + ) + + ac_container_ul = soup.select_one("ul#team_lead_ac_container.ac_container") + assert ac_container_ul + + lis = ac_container_ul.select("li") + assert len(lis) == 1 + actual_input_field = lis[0].select_one("input") + assert actual_input_field.attrs["type"] == "text" + assert "name" not in actual_input_field.attrs + assert actual_input_field.attrs["aria-controls"] == "team_lead__items" + assert actual_input_field.attrs["hx-get"] == reverse( + "autocomplete:items", args=["PersonAC4"] + ) + assert actual_input_field.attrs["hx-include"] == "#team_lead" + assert actual_input_field.attrs["hx-target"] == "#team_lead__items" + assert ( + 'getElementById("team_lead__textinput")' in actual_input_field.attrs["hx-vals"] + ) + assert '"component_prefix": "",' in actual_input_field.attrs["hx-vals"] + assert '"field_name": "team_lead",' in actual_input_field.attrs["hx-vals"] + assert "value" not in actual_input_field.attrs + + +def test_render_widget_in_form_non_empty(): + class FormWithSingle(forms.ModelForm): + class Meta: + model = Team + fields = ["team_lead"] + + widgets = { + "team_lead": AutocompleteWidget( + ac_class=PersonAC4, + ) + } + + lead = PersonFactory() + record = TeamFactory(team_lead=lead) + + create_form = FormWithSingle( + instance=record, + ) + + rendered = render_template(single_form_template, {"form": create_form}) + + soup = soup_from_str(rendered) + + # check input is populated, + input = soup.select_one("#team_lead span > input[type='hidden']") + assert input.attrs["name"] == "team_lead" + assert input.attrs["value"] == str(lead.id) + + ac_container_ul = soup.select_one("ul#team_lead_ac_container.ac_container") + assert ac_container_ul + + lis = ac_container_ul.select("li") + assert len(lis) == 1 + actual_input_field = lis[0].select_one("input") + assert actual_input_field.attrs["type"] == "text" + assert actual_input_field.attrs["value"] == lead.name + assert "multiselect" not in actual_input_field.attrs["hx-vals"] + + +def test_render_widget_multi_empty(): + class FormWithMulti(forms.ModelForm): + class Meta: + model = Team + fields = ["members"] + + widgets = { + "members": AutocompleteWidget( + ac_class=PersonAC4, options={"multiselect": True} + ) + } + + people = PersonFactory.create_batch(5) + + create_form = FormWithMulti() + + rendered = render_template(single_form_template, {"form": create_form}) + + soup = soup_from_str(rendered) + + +def test_render_widget_multi_non_empty(): + + class FormWithMulti(forms.ModelForm): + class Meta: + model = Team + fields = ["members"] + + widgets = { + "members": AutocompleteWidget( + ac_class=PersonAC4, options={"multiselect": True} + ) + } + + people = PersonFactory.create_batch(5) + record = TeamFactory() + record.members.set(people[:2]) + + edit_form = FormWithMulti(instance=record) + + rendered = render_template(single_form_template, {"form": edit_form}) + + soup = soup_from_str(rendered) + + component = soup.select_one("#members") + hidden_inputs = component.select('span > input[type="hidden"]') + + assert len(hidden_inputs) == 2 + assert hidden_inputs[0].attrs["name"] == "members" + values = {int(i.attrs["value"]) for i in hidden_inputs} + assert values == {people[0].id, people[1].id} + + # check untoggle buttons, + ac_items = soup.select("ul#members_ac_container.ac_container > li.chip") + assert len(ac_items) == 2 + for li in ac_items: + assert li.select_one("span").text in {people[0].name, people[1].name} + delete_button = li.select_one("a") + assert delete_button.attrs["hx-get"] == reverse( + "autocomplete:toggle", args=["PersonAC4"] + ) + assert ( + delete_button.attrs["hx-params"] + == "members,field_name,item,component_prefix,required,multiselect,remove" + ) + assert "multiselect" in delete_button.attrs["hx-vals"] + assert delete_button.attrs["hx-vals"] + assert delete_button.attrs["hx-include"] == "#members" + assert delete_button.attrs["hx-swap"] == "delete" + assert delete_button.attrs["hx-target"] == "this" + assert delete_button.attrs["href"] == "#" + + +def test_with_formset(): + p1 = PersonFactory() + p2 = PersonFactory() + p3 = PersonFactory() + people = [p1, p2, p3] + team1 = TeamFactory(team_lead=p1) + team2 = TeamFactory(team_lead=p2) + team3 = TeamFactory(team_lead=None) + + class FormWithSingle(forms.ModelForm): + class Meta: + model = Team + fields = ["team_lead"] + + widgets = { + "team_lead": AutocompleteWidget( + ac_class=PersonAC4, + ) + } + + forms.modelformset_factory(Team, form=FormWithSingle) + + template = Template( + """ +
+ {{ formset.empty_form }} +
+
+ {{ formset.management_form }} +
+ {% for form in formset %} +
+ {{ form.as_p }} +
+ {% endfor %} + """ + ) + + formset = forms.modelformset_factory(Team, form=FormWithSingle, extra=0)( + queryset=Team.objects.all() + ) + + rendered = render_template(template, {"formset": formset}) + + soup = soup_from_str(rendered) + + empty_form = soup.select_one("#empty-form-container") + assert empty_form + + formset_forms = soup.select(".form-container") + for ix, form in enumerate(formset_forms): + hidden_input = form.select_one( + f"input[type='hidden'][name='form-{ix}-team_lead']" + ) + if ix in (0, 1): + assert hidden_input.attrs["value"] == str(people[ix].id) + if ix == 2: + assert not hidden_input + + +def test_custom_options(): + class FormWithSingle(forms.ModelForm): + class Meta: + model = Team + fields = ["team_lead"] + + widgets = { + "team_lead": AutocompleteWidget( + ac_class=PersonAC4, + attrs={ + "required": True, + "disabled": True, + }, + options={ + "component_prefix": "my_prefix", + "placeholder": "my placeholder", + }, + ) + } + + create_form = FormWithSingle() + + rendered = render_template(single_form_template, {"form": create_form}) + + soup = soup_from_str(rendered) + + input = soup.select_one("ul li input[type='text']") + + assert input.attrs["placeholder"] == "my placeholder" + assert "required" in input.attrs + assert "disabled" in input.attrs + + hx_vals = input.attrs["hx-vals"] + assert '"placeholder": "my placeholder"' in hx_vals + assert '"required": true' in hx_vals + assert '"disabled": true' in hx_vals + + +def test_custom_options_not_required(): + class FormWithSingle(forms.ModelForm): + class Meta: + model = Team + fields = ["team_lead"] + + widgets = { + "team_lead": AutocompleteWidget( + ac_class=PersonAC4, + ) + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["team_lead"].required = False + + create_form = FormWithSingle() + + rendered = render_template(single_form_template, {"form": create_form}) + + soup = soup_from_str(rendered) + + input = soup.select_one("ul li input[type='text']") + + assert "required" not in input.attrs + + hx_vals = input.attrs["hx-vals"] + assert "required" not in hx_vals + + +def test_disabled_multi(): + + class FormWithMulti(forms.ModelForm): + class Meta: + model = Team + fields = ["members"] + + widgets = { + "members": AutocompleteWidget( + ac_class=PersonAC4, options={"multiselect": True} + ) + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["members"].required = False + self.fields["members"].disabled = True + + people = PersonFactory.create_batch(5) + + record = TeamFactory() + record.members.set(people[:2]) + + edit_form = FormWithMulti() + edit_form = FormWithMulti(instance=record) + + rendered = render_template(single_form_template, {"form": edit_form}) + + soup = soup_from_str(rendered) + + ac_container = soup.select_one(".ac_container") + chips = ac_container.select("li.chip") + assert len(chips) == 2 + for chip in chips: + assert not chip.select_one("a") + input_li = ac_container.select_one("li.input") + assert not input_li.select_one("input") + assert input_li.select_one("output#members__textinput") + + +def test_extra_hx_vals(): + @register + class PersonAC4WithHxVals(PersonAC4): + @classmethod + def get_extra_text_input_hx_vals(cls): + return {"extra": '"value"'} + + class FormWithSingle(forms.ModelForm): + class Meta: + model = Team + fields = ["team_lead"] + + widgets = { + "team_lead": AutocompleteWidget( + ac_class=PersonAC4WithHxVals, + ) + } + + create_form = FormWithSingle() + + rendered = render_template(single_form_template, {"form": create_form}) + + soup = soup_from_str(rendered) + + input = soup.select_one("ul li input[type='text']") + hx_vals = input.attrs["hx-vals"] + + assert '"extra": "value"' in hx_vals diff --git a/tests/utils_for_test.py b/tests/utils_for_test.py new file mode 100644 index 0000000..f6709f0 --- /dev/null +++ b/tests/utils_for_test.py @@ -0,0 +1,30 @@ +from bs4 import BeautifulSoup +from django.http import QueryDict +from django.test.client import MULTIPART_CONTENT, Client + + +def get_soup(response): + content = response.content + return soup_from_str(content) + + +def soup_from_str(content): + soup = BeautifulSoup(content, "html.parser") + return soup + + +def put_request_as_querystring(client, url, data): + """ + for some reason, + htmx sends PUT requests as querystings?? + """ + qs = QueryDict(mutable=True) + for k, v in data.items(): + if isinstance(v, list): + qs.setlist(k, v) + else: + qs.update({k: v}) + + query_string = qs.urlencode() + response = client.put(url, data=query_string) + return response