[ADD] BASE: Horilla generic bulk update method

This commit is contained in:
Horilla
2024-08-28 12:01:40 +05:30
parent 78549149ce
commit 3eca5bb04c
10 changed files with 591 additions and 115 deletions

View File

@@ -4,13 +4,15 @@ horilla/cbv_methods.py
import types
import uuid
from typing import Any
from urllib.parse import urlencode
from venv import logger
from django import template
from django import forms, template
from django.contrib import messages
from django.core.cache import cache as CACHE
from django.core.paginator import Paginator
from django.db import models
from django.db.models.fields.related import ForeignKey
from django.db.models.fields.related_descriptors import (
ForwardManyToOneDescriptor,
@@ -31,6 +33,67 @@ from horilla import settings
from horilla.horilla_middlewares import _thread_locals
from horilla_views.templatetags.generic_template_filters import getattribute
FIELD_WIDGET_MAP = {
models.CharField: forms.TextInput(attrs={"class": "oh-input w-100"}),
models.ImageField: forms.FileInput(
attrs={"type": "file", "class": "oh-input w-100"}
),
models.FileField: forms.FileInput(
attrs={"type": "file", "class": "oh-input w-100"}
),
models.TextField: forms.Textarea(
{
"class": "oh-input w-100",
"rows": 2,
"cols": 40,
}
),
models.IntegerField: forms.NumberInput(attrs={"class": "oh-input w-100"}),
models.FloatField: forms.NumberInput(attrs={"class": "oh-input w-100"}),
models.DecimalField: forms.NumberInput(attrs={"class": "oh-input w-100"}),
models.EmailField: forms.EmailInput(attrs={"class": "oh-input w-100"}),
models.DateField: forms.DateInput(
attrs={"type": "date", "class": "oh-input w-100"}
),
models.DateTimeField: forms.DateTimeInput(
attrs={"type": "date", "class": "oh-input w-100"}
),
models.TimeField: forms.TimeInput(
attrs={"type": "time", "class": "oh-input w-100"}
),
models.BooleanField: forms.Select({"class": "oh-select oh-select-2 w-100"}),
models.ForeignKey: forms.Select({"class": "oh-select oh-select-2 w-100"}),
models.ManyToManyField: forms.SelectMultiple(
attrs={"class": "oh-select oh-select-2 select2-hidden-accessible"}
),
models.OneToOneField: forms.Select({"class": "oh-select oh-select-2 w-100"}),
}
MODEL_FORM_FIELD_MAP = {
models.CharField: forms.CharField,
models.TextField: forms.CharField, # Textarea can be specified as a widget
models.IntegerField: forms.IntegerField,
models.FloatField: forms.FloatField,
models.DecimalField: forms.DecimalField,
models.ImageField: forms.FileField,
models.FileField: forms.FileField,
models.EmailField: forms.EmailField,
models.DateField: forms.DateField,
models.DateTimeField: forms.DateTimeField,
models.TimeField: forms.TimeField,
models.BooleanField: forms.BooleanField,
models.ForeignKey: forms.ModelChoiceField,
models.ManyToManyField: forms.ModelMultipleChoiceField,
models.OneToOneField: forms.ModelChoiceField,
}
BOOLEAN_CHOICES = (
("", "----------"),
(True, "Yes"),
(False, "No"),
)
def decorator_with_arguments(decorator):
"""
@@ -76,6 +139,10 @@ def decorator_with_arguments(decorator):
def login_required(view_func):
"""
Decorator to check authenticity of users
"""
def wrapped_view(self, *args, **kwargs):
request = getattr(_thread_locals, "request")
if not getattr(self, "request", None):
@@ -108,6 +175,10 @@ def login_required(view_func):
@decorator_with_arguments
def permission_required(function, perm):
"""
Decorator to validate user permissions
"""
def _function(self, *args, **kwargs):
request = getattr(_thread_locals, "request")
if not getattr(self, "request", None):
@@ -127,6 +198,20 @@ def permission_required(function, perm):
return _function
def hx_request_required(function):
"""
Decorator method that only allow HTMX metod to enter
"""
def _function(request, *args, **kwargs):
key = "HTTP_HX_REQUEST"
if key not in request.META.keys():
return render(request, "405.html")
return function(request, *args, **kwargs)
return _function
def csrf_input(request):
return format_html(
'<input type="hidden" name="csrfmiddlewaretoken" value="{}">',
@@ -211,19 +296,6 @@ def update_initial_cache(request: object, cache: dict, view: object):
return
def structured(self):
"""
Render the form fields as HTML table rows with Bootstrap styling.
"""
request = getattr(_thread_locals, "request", None)
context = {
"form": self,
"request": request,
}
table_html = render_to_string("generic/form.html", context)
return table_html
class Reverse:
reverse: bool = True
page: str = ""
@@ -331,3 +403,66 @@ def update_saved_filter_cache(request, cache):
},
)
return cache
def get_nested_field(model_class: models.Model, field_name: str) -> object:
"""
Recursion function to execute nested field logic
"""
if "__" in field_name:
splits = field_name.split("__", 1)
related_model_class = getmodelattribute(
model_class,
splits[0],
).related.related_model
return get_nested_field(related_model_class, splits[1])
field = getattribute(model_class, field_name)
return field
def get_field_class_map(model_class: models.Model, bulk_update_fields: list) -> dict:
"""
Returns a dictionary mapping field names to their corresponding field classes
for a given model class, including related fields(one-to-one).
"""
field_class_map = {}
for field_name in bulk_update_fields:
field = get_nested_field(model_class, field_name)
field_class_map[field_name] = field.field
return field_class_map
def structured(self):
"""
Render the form fields as HTML table rows with Bootstrap styling.
"""
request = getattr(_thread_locals, "request", None)
context = {
"form": self,
"request": request,
}
table_html = render_to_string("generic/form.html", context)
return table_html
def value_to_field(field: object, value: list) -> Any:
"""
return value according to the format of the field
"""
if isinstance(field, models.ManyToManyField):
return [int(val) for val in value]
elif isinstance(
field,
(
models.DateField,
models.DateTimeField,
models.CharField,
models.EmailField,
models.TextField,
models.TimeField,
),
):
value = value[0]
return value
value = eval(str(value[0]))
return value

View File

@@ -2,12 +2,27 @@
horilla_views/forms.py
"""
import os
from django import forms
from django.contrib import messages
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.template.loader import render_to_string
from django.utils.safestring import SafeText
from django.utils.translation import gettext_lazy as _trans
from horilla.horilla_middlewares import _thread_locals
from horilla_views import models
from horilla_views.cbv_methods import (
BOOLEAN_CHOICES,
FIELD_WIDGET_MAP,
MODEL_FORM_FIELD_MAP,
get_field_class_map,
structured,
value_to_field,
)
from horilla_views.templatetags.generic_template_filters import getattribute
class ToggleColumnForm(forms.Form):
@@ -75,3 +90,134 @@ class SavedFilterForm(forms.ModelForm):
attrs["placeholder"] = "Saved filter title"
if self.instance.pk:
self.verbose_name = self.instance.title
class DynamicBulkUpdateForm(forms.Form):
"""
DynamicBulkUpdateForm
"""
verbose_name = _trans("Bulk Update")
def __init__(
self,
*args,
root_model: models.models.Model = None,
bulk_update_fields: list = [],
ids: list = [],
**kwargs
):
self.ids = ids
self.root_model = root_model
self.bulk_update_fields = sorted(
bulk_update_fields, key=lambda x: x.count("__")
)
self.structured = structured
mappings = get_field_class_map(root_model, bulk_update_fields)
self.request = getattribute(_thread_locals, "request")
super().__init__(*args, **kwargs)
for key, val in mappings.items():
widget = FIELD_WIDGET_MAP.get(type(val))
field = MODEL_FORM_FIELD_MAP.get(type(val))
if widget and field:
if isinstance(val, models.models.BooleanField):
self.fields[key] = forms.ChoiceField(
choices=BOOLEAN_CHOICES,
widget=widget,
label=val.verbose_name.capitalize(),
required=False,
)
continue
elif not getattribute(val, "related_model"):
self.fields[key] = field(
widget=widget,
label=val.verbose_name.capitalize(),
required=False,
)
continue
queryset = val.related_model.objects.all()
self.fields[key] = field(
widget=widget,
queryset=queryset,
label=val.verbose_name,
required=False,
)
def save(self, *args, **kwargs):
"""
Bulk save method
"""
mappings = get_field_class_map(self.root_model, self.bulk_update_fields)
data_mapp = {}
data_m2m_mapp = {}
relation_mapp = {}
map_queryset = {}
fiels_mapping = {}
parent_model = self.root_model
for key, val in mappings.items():
field = MODEL_FORM_FIELD_MAP.get(type(val))
if field:
if not fiels_mapping.get(val.model):
fiels_mapping[val.model] = {}
if not data_m2m_mapp.get(val.model):
data_m2m_mapp[val.model] = {}
if not data_mapp.get(val.model):
data_mapp[val.model] = {}
if not relation_mapp.get(val.model):
if val.model == self.root_model:
relation_mapp[val.model] = "id__in"
else:
related_key = key.split("__")[-2]
field = getattribute(parent_model, related_key)
relation_mapp[val.model] = (
field.related.field.name
+ "__"
+ relation_mapp[parent_model]
)
parent_model = val.model
files = self.files.getlist(key)
value = self.data.getlist(key)
if (not value or not value[0]) and not files:
continue
key = key.split("__")[-1]
model_field = getattribute(val.model, key).field
if isinstance(model_field, models.models.ManyToManyField):
data_m2m_mapp[val.model][key] = value
continue
if files and isinstance(
model_field,
(
models.models.FileField,
models.models.ImageField,
),
):
file_path = os.path.join(model_field.upload_to, files[0].name)
data_mapp[val.model][key] = file_path
fiels_mapping[val.model][model_field] = files[0]
continue
data_mapp[val.model][key] = value[0]
for model, data in data_mapp.items():
queryset = model.objects.filter(**{relation_mapp[model]: self.ids})
# here fields, files, and related fields-
# get updated but need to save the files manually
queryset.update(**data)
map_queryset[model] = queryset
m2m_data = data_m2m_mapp[model]
# saving m2m
if m2m_data:
for field, ids in m2m_data.items():
related_objects = getattr(
model, field
).field.related_model.objects.filter(id__in=ids)
for instance in queryset:
getattr(instance, field).set(related_objects)
for model, files in fiels_mapping.items():
if files:
for field, file in files.items():
file_path = os.path.join(field.upload_to, file.name)
default_storage.save(file_path, ContentFile(file.read()))
messages.success(self.request, _trans("Selected Records updated"))

View File

@@ -8,10 +8,14 @@ from urllib.parse import parse_qs
from bs4 import BeautifulSoup
from django import forms
from django.contrib import messages
from django.core.cache import cache as CACHE
from django.core.paginator import Page
from django.http import HttpRequest, HttpResponse, QueryDict
from django.shortcuts import render
from django.urls import resolve, reverse
from django.utils.decorators import method_decorator
from django.utils.translation import gettext_lazy as _trans
from django.views.generic import DetailView, FormView, ListView, TemplateView
from base.methods import closest_numbers, get_key_instances
@@ -21,16 +25,18 @@ from horilla.horilla_middlewares import _thread_locals
from horilla_views import models
from horilla_views.cbv_methods import (
get_short_uuid,
hx_request_required,
paginator_qry,
sortby,
structured,
update_initial_cache,
update_saved_filter_cache,
)
from horilla_views.forms import ToggleColumnForm
from horilla_views.forms import DynamicBulkUpdateForm, ToggleColumnForm
from horilla_views.templatetags.generic_template_filters import getattribute
@method_decorator(hx_request_required, name="dispatch")
class HorillaListView(ListView):
"""
HorillaListView
@@ -87,6 +93,9 @@ class HorillaListView(ListView):
export_fields: list = []
verbose_name: str = ""
bulk_update_fields: list = []
bulk_template: str = "generic/bulk_form.html"
def __init__(self, **kwargs: Any) -> None:
if not self.view_id:
self.view_id = get_short_uuid(4)
@@ -112,6 +121,78 @@ class HorillaListView(ListView):
if column[1] in hidden_fields:
self.visible_column.remove(column)
def bulk_update_accessibility(self) -> bool:
"""
Accessibility method for bulk update
"""
return self.request.user.has_perm(
f"{self.model._meta.app_label}.change_{self.model.__name__.lower()}"
)
def serve_bulk_form(self, request: HttpRequest) -> HttpResponse:
"""
Bulk form serve method
"""
if not self.bulk_update_accessibility():
return HttpResponse("You dont have permission")
form = self.get_bulk_form()
form.verbose_name = (
form.verbose_name
+ f" ({len((eval(request.GET.get('instance_ids','[]'))))} {_trans('Records')})"
)
return render(
request,
self.bulk_template,
{"form": form, "post_bulk_path": self.post_bulk_path},
)
def handle_bulk_submission(self, request: HttpRequest) -> HttpRequest:
"""
This method to handle bulk update form submission
"""
if not self.bulk_update_accessibility():
return HttpResponse("You dont have permission")
instance_ids = request.GET.get("instance_ids", "[]")
instance_ids = eval(instance_ids)
form = DynamicBulkUpdateForm(
request.POST,
request.FILES,
root_model=self.model,
bulk_update_fields=self.bulk_update_fields,
ids=instance_ids,
)
if instance_ids and form.is_valid():
form.save()
script_id = get_short_uuid(length=3, prefix="bulk")
return HttpResponse(
f"""
<script id="{script_id}">
$("#{script_id}").closest(".oh-modal--show").removeClass("oh-modal--show");
$(".reload-record").click()
$("#reloadMessagesButton").click()
</script>
"""
)
if not instance_ids:
messages.info(request, _trans("No records selected"))
return render(
request,
self.bulk_template,
{"form": form, "post_bulk_path": self.post_bulk_path},
)
def get_bulk_form(self):
"""
Bulk from generating method
"""
# Bulk update feature
return DynamicBulkUpdateForm(
root_model=self.model, bulk_update_fields=self.bulk_update_fields
)
def get_queryset(self):
if not self.queryset:
self.queryset = super().get_queryset()
@@ -270,6 +351,29 @@ class HorillaListView(ListView):
urlpatterns.append(path(self.export_path, self.export_data))
context["export_path"] = self.export_path
if self.bulk_update_fields and self.bulk_update_accessibility():
get_bulk_path = (
f"get-bulk-update-{self.view_id}-{self.request.session.session_key}/"
)
post_bulk_path = (
f"post-bulk-update-{self.view_id}-{self.request.session.session_key}/"
)
self.post_bulk_path = post_bulk_path
urlpatterns.append(
path(
get_bulk_path,
self.serve_bulk_form,
)
)
urlpatterns.append(
path(
post_bulk_path,
self.handle_bulk_submission,
)
)
context["bulk_update_fields"] = self.bulk_update_fields
context["bulk_path"] = get_bulk_path
return context
def select_all(self, *args, **kwargs):
@@ -380,6 +484,7 @@ class HorillaSectionView(TemplateView):
return context
@method_decorator(hx_request_required, name="dispatch")
class HorillaDetailedView(DetailView):
"""
HorillDetailedView
@@ -437,6 +542,7 @@ class HorillaDetailedView(DetailView):
return context
@method_decorator(hx_request_required, name="dispatch")
class HorillaTabView(TemplateView):
"""
HorillaTabView
@@ -469,6 +575,7 @@ class HorillaTabView(TemplateView):
return context
@method_decorator(hx_request_required, name="dispatch")
class HorillaCardView(ListView):
"""
HorillaCardView
@@ -609,7 +716,12 @@ class HorillaCardView(ListView):
return context
@method_decorator(hx_request_required, name="dispatch")
class ReloadMessages(TemplateView):
"""
Reload messages
"""
template_name = "generic/messages.html"
def get_context_data(self, **kwargs):
@@ -639,6 +751,7 @@ def save(self: forms.ModelForm, commit=True, *args, **kwargs):
return response
@method_decorator(hx_request_required, name="dispatch")
class HorillaFormView(FormView):
"""
HorillaFormView
@@ -811,6 +924,7 @@ class HorillaFormView(FormView):
return form
@method_decorator(hx_request_required, name="dispatch")
class HorillaNavView(TemplateView):
"""
HorillaNavView
@@ -862,6 +976,7 @@ class HorillaNavView(TemplateView):
return context
@method_decorator(hx_request_required, name="dispatch")
class HorillaProfileView(DetailView):
"""
GenericHorillaProfileView

View File

@@ -0,0 +1,4 @@
<form hx-post="/{{post_bulk_path}}?{{request.GET.urlencode}}" method="post" hx-swap="outerHTML" hx-encoding="multipart/form-data">
{% csrf_token %}
{% include "generic/form.html" %}
</form>

View File

@@ -37,7 +37,7 @@
</button>
</div>
{% endif %}
<div class="oh-general__tab-target oh-profile-section" id="personal">
<div class="oh-general__tab-target oh-profile-section" id="{{form.container_id}}">
<div class="oh-profile-section__card row">
<div class="row" style="padding-right: 0;">
<div class="col-12" style="padding-right: 0;">{{ form.non_field_errors }}</div>
@@ -83,3 +83,11 @@
</div>
</div>
</div>
<script>
$(document).ready(function () {
$("select").on("select2:select", function (e) {
$(".leave-message").hide();
$(this).closest("select")[0].dispatchEvent(new Event("change"));
});
});
</script>

View File

@@ -171,6 +171,16 @@
</script>
{% endif %}
{% else %}
{% if card_status_indications %}
<div class="d-flex flex-row-reverse">
{% for indication in card_status_indications %}
<span class="m-1" style="cursor: pointer;margin-left: 7px;" {{indication.2|safe}}>
<span class="oh-dot oh-dot--small me-1 {{indication.0}}"></span>
{{indication.1}}
</span>
{% endfor %}
</div>
{% endif %}
<div class="oh-wrapper" align="center" style="margin-top: 7vh; margin-bottom:7vh;">
<div align="center">
<img src="{% static "images/ui/search.svg" %}" class="oh-404__image" alt="Page not found. 404.">

View File

@@ -81,6 +81,7 @@
{% endfor %}
</div>
</div>
<div class="m-3 " id="enlargeattachmentContainer"></div>
</div>
</div>
</div>

View File

@@ -1,5 +1,15 @@
{% load static i18n generic_template_filters %}
<div id="{{view_id|safe}}">
<div
class="oh-modal"
id="bulkUpdateModal{{view_id|safe}}"
role="dialog"
aria-labelledby="bulkUpdateModal{{view_id|safe}}"
aria-hidden="true"
>
<div class="oh-modal__dialog" id="bulkUpdateModalBody{{view_id|safe}}"></div>
</div>
{% include "generic/export_fields_modal.html" %}
<script>
if (!$(".HTV").length) {
@@ -45,14 +55,37 @@
</span> {% trans "Selected" %}
</div>
<div
id="export_{{view_id}}"
class="oh-checkpoint-badge text-info d-none"
style="cursor: pointer;"
data-toggle="oh-modal-toggle"
data-target="#exportFields{{view_id|safe}}"
id="export_{{view_id}}"
class="oh-checkpoint-badge text-info d-none"
style="cursor: pointer;"
data-toggle="oh-modal-toggle"
data-target="#exportFields{{view_id|safe}}"
>
{% trans "Export" %}
</div>
{% if bulk_path %}
<div
id="bulk_udate_{{view_id}}"
class="oh-checkpoint-badge text-warning d-none"
style="cursor: pointer;"
data-toggle="oh-modal-toggle"
data-target="#bulkUpdateModal{{view_id|safe}}"
onclick="
ids = $('#{{selected_instances_key_id}}').attr('data-ids')
$('#bulk_update_get_form{{view_id}}').closest('form').find('[name=instance_ids]').val(ids);
$('#bulk_update_get_form{{view_id}}').click()
"
>
{% trans "Update" %}
</div>
<form
hx-get="/{{bulk_path}}"
hx-target="#bulkUpdateModalBody{{view_id|safe}}">
<input type="hidden" name="instance_ids">
<button type="submit" id="bulk_update_get_form{{view_id}}" hidden>
</button>
</form>
{% endif %}
{% for filter in stored_filters %}
<div class="oh-hover-btn-container"
hx-get="{{request.path}}?{{filter.urlencode}}"
@@ -393,6 +426,16 @@
});
</script>
{% else %}
{% if row_status_indications %}
<div class="d-flex flex-row-reverse">
{% for indication in row_status_indications %}
<span class="m-1" style="cursor: pointer;margin-left: 7px;" {{indication.2|safe}}>
<span class="oh-dot oh-dot--small me-1 {{indication.0}}"></span>
{{indication.1}}
</span>
{% endfor %}
</div>
{% endif %}
<div class="oh-wrapper" align="center" style="margin-top: 7vh; margin-bottom:7vh;">
<div align="center">
<img src="{% static "images/ui/search.svg" %}" class="oh-404__image" alt="Page not found. 404.">

View File

@@ -131,10 +131,11 @@
>
<ul class="oh-dropdown__items">
{% for action in actions %}
{% if action.accessibility|accessibility:instance %}
<li class="oh-dropdown__item">
<a href="#" class="oh-profile-dropdown-link" {{action.attrs|safe}}>
<img
src="{{action.icon}}"
src="{{action.src}}"
style="width: 20px; height: auto"
title="{{action.title}}"
/>
@@ -145,6 +146,7 @@
</button>
</a>
</li>
{% endif %}
{% endfor %}
</ul>
</div>

View File

@@ -3,102 +3,114 @@
{% comment %} {% include "generic/components.html" %} {% endcomment %}
{% comment %} {% include "attendance/attendance/attendance_nav.html" %} {% endcomment %}
{% load i18n generic_template_filters %}
<div class="oh-tabs HTV">
<script>
$("#reloadMessagesButton").click()
</script>
<div class="HTV"></div>
<ul class="oh-tabs__tablist">
{% for tab in tabs %}
<li
class="oh-tabs__tab d-flex {% if forloop.counter == 1 and not active_target %} oh-tabs__tab--active {% endif %}"
data-target="#{{view_id}}{{forloop.counter}}"
hx-get="{{tab.url}}?{{request.GET.urlencode}}"
hx-target="#{{view_id}}{{forloop.counter}}"
hx-trigger="load"
onclick="switchTab(event)"
>
{{tab.title}}
<div class="d-flex">
<div class="oh-tabs__input-badge-container" onclick="event.stopPropagation()">
<span
class="oh-badge oh-badge--secondary oh-badge--small oh-badge--round ms-2 mr-2"
id="badge-{{view_id}}{{forloop.counter}}"
{% if tab.badge_label %}
data-badge-label="{{tab.badge_label}}"
title="0 {{tab.badge_label}}"
{% else %}
title="0 {% trans "Records" %}"
{% endif %}
onclick="event.stopPropagation()"
>
0
</span>
</div>
{% if tab.actions %}
<div onclick="event.stopPropagation()" class="oh-dropdown" x-data="{open: false}">
<button
class="oh-btn oh-stop-prop oh-btn--transparent oh-accordion-meta__btn"
@click="open = !open"
@click.outside="open = false"
title="Actions"
>
<ion-icon
name="ellipsis-vertical"
role="img"
class="md hydrated"
aria-label="ellipsis vertical"
></ion-icon>
</button>
<div
class="oh-dropdown__menu oh-dropdown__menu--right"
x-show="open"
style="display: none"
>
<ul class="oh-dropdown__items">
{% for action in tab.actions %}
<li class="oh-dropdown__item">
<a {{action.attrs|safe}}>{{action.action}}</a>
</li>
{% endfor %}
</ul>
<div id="{{view_id}}">
<div class="oh-tabs HTV">
<script>
$("#reloadMessagesButton").click()
</script>
<div class="HTV"></div>
<ul class="oh-tabs__tablist">
{% for tab in tabs %}
<li
class="oh-tabs__tab d-flex {% if forloop.counter == 1 and not active_target %} oh-tabs__tab--active {% endif %}"
data-target="#{{view_id}}{{forloop.counter}}"
hx-get="{{tab.url}}?{{request.GET.urlencode}}"
hx-target="#{{view_id}}{{forloop.counter}}"
hx-trigger="load"
onclick="switchTab(event)"
>
{{tab.title}}
<div class="d-flex">
<div class="oh-tabs__input-badge-container" onclick="event.stopPropagation()">
<span
class="oh-badge oh-badge--secondary oh-badge--small oh-badge--round ms-2 mr-2"
id="badge-{{view_id}}{{forloop.counter}}"
{% if tab.badge_label %}
data-badge-label="{{tab.badge_label}}"
title="0 {{tab.badge_label}}"
{% else %}
title="0 {% trans "Records" %}"
{% endif %}
onclick="event.stopPropagation()"
>
0
</span>
</div>
{% if tab.actions %}
<div onclick="event.stopPropagation()" class="oh-dropdown" x-data="{open: false}">
<button
class="oh-btn oh-stop-prop oh-btn--transparent oh-accordion-meta__btn"
@click="open = !open"
@click.outside="open = false"
title="Actions"
>
<ion-icon
name="ellipsis-vertical"
role="img"
class="md hydrated"
aria-label="ellipsis vertical"
></ion-icon>
</button>
<div
class="oh-dropdown__menu oh-dropdown__menu--right"
x-show="open"
style="display: none"
>
<ul class="oh-dropdown__items">
{% for action in tab.actions %}
{% if instance %}
{% if action.accessibility|accessibility:instance %}
<li class="oh-dropdown__item">
<a {{action.attrs|safe}}>{{action.action}}</a>
</li>
{% endif %}
{% else %}
<li class="oh-dropdown__item">
<a {{action.attrs|safe}}>{{action.action}}</a>
</li>
{% endif %}
{% endfor %}
</ul>
</div>
</div>
{% endif %}
</div>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
<div class="oh-tabs__contents">
{% for tab in tabs %}
<div
class="oh-tabs__content {% if forloop.counter == 1 and not active_target %} oh-tabs__content--active {% endif %}"
id="{{view_id}}{{forloop.counter}}"
>
<div class="animated-background"></div>
</li>
{% endfor %}
</ul>
<div class="oh-tabs__contents">
{% for tab in tabs %}
<div
class="oh-tabs__content {% if forloop.counter == 1 and not active_target %} oh-tabs__content--active {% endif %}"
id="{{view_id}}{{forloop.counter}}"
>
<div class="animated-background"></div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
<script>
$("li.oh-tabs__tab").click(function (e) {
var target = `li[data-target="${$(this).attr("data-target")}"]`
e.preventDefault();
$.ajax({
type: "get",
url: "{% url 'active-tab' %}",
data: {
"path":"{{request.path}}",
"target":target,
},
success: function (response) {
<script>
$("li.oh-tabs__tab").click(function (e) {
var target = `li[data-target="${$(this).attr("data-target")}"]`
e.preventDefault();
$.ajax({
type: "get",
url: "{% url 'active-tab' %}",
data: {
"path":"{{request.path}}",
"target":target,
},
success: function (response) {
}
}
});
});
});
{% if active_target %}
$("div.oh-tabs").find(`{{active_target|safe}}`).click();
{% endif %}
</script>
{% if active_target %}
$("div.oh-tabs").find(`{{active_target|safe}}`).click();
if (!$(".oh-tabs__tab--active").length) {
$("#{{view_id}}").find(".oh-tabs__tab").first().click()
}
{% endif %}
</script>
</div>