Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/global search #536

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
254 changes: 254 additions & 0 deletions backend/core/api/base/global_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
from django.shortcuts import render

from backend.core.models import User, TeamMemberPermission
from backend.models import Client, Invoice


def global_search_endpoint(request):
search_text = request.GET.get("search", "").strip().lower()

# Mapping for pages
page_mappings = {
"dashboard": "/dashboard/",
"receipts": "/dashboard/receipts/",
"invoices single": "/dashboard/invoices/single/",
"invoices recurring": "/dashboard/invoices/recurring/",
"clients": "/dashboard/clients/",
"file storage": "/dashboard/file_storage/",
"settings": "/dashboard/settings/",
"api keys": "/dashboard/settings/api_keys/",
}

# Available services
services = {
"dashboard": {
"description": "Access your main dashboard",
"icon": "fa-home",
"url": page_mappings["dashboard"],
},
"invoices": {
"description": "Simplify your billing for customers",
"icon": "fa-file-invoice",
"url": page_mappings["invoices single"],
"features": {
"Single": page_mappings["invoices single"],
"Recurring": page_mappings["invoices recurring"],
},
},
"clients": {
"description": "Simplified customer information storage",
"icon": "fa-users",
"url": page_mappings["clients"],
"features": {
"View All": page_mappings["clients"],
"Create new customer": f"{page_mappings['clients']}create/",
},
},
"file storage": {
"description": "Manage your files securely",
"icon": "fa-folder",
"url": page_mappings["file storage"],
},
"receipts": {
"description": "Access your receipts",
"icon": "fa-receipt",
"url": page_mappings["receipts"],
},
"settings": {
"description": "Configure system settings",
"icon": "fa-cogs",
"url": page_mappings["settings"],
"features": {
"API Keys": page_mappings["api keys"],
},
},
}

# Filter services based on search text
filtered_services = {}
for key, value in services.items():
# Check if search_text matches service name
if search_text in key.lower():
filtered_services[key] = value
continue

# Check if search_text matches any feature name
if "features" in value:
for feature_name in value["features"]:
if search_text in feature_name.lower():
filtered_services[key] = value
break

resources = {
"invoice": [],
"client": [],
}

# Fetch only permitted clients
def get_permitted_clients(request):
if isinstance(request.actor, User):
return Client.objects.filter(user=request.user)
elif (
request.team.is_owner(request.user)
or "clients:read" in TeamMemberPermission.objects.filter(team=request.team, user=request.user).first().scopes
):
return Client.objects.filter(organization=request.team)
else:
return Client.objects.none()

# Fetch only permitted invoices
def get_permitted_invoices(request):
if isinstance(request.actor, User):
return Invoice.objects.filter(user=request.user)
elif (
request.team.is_owner(request.user)
or "clients:read" in TeamMemberPermission.objects.filter(team=request.team, user=request.user).first().scopes
):
return Invoice.objects.filter(organization=request.team)
else:
return Invoice.objects.none()

# Fetch resources only if search_text is present

if not search_text:
return render(
request,
"base/topbar/_search_dropdown.html",
{
"services": filtered_services,
},
)

# Track added IDs to avoid duplicates (duplicates appeared when I searched for exact client or Invoice)
added_invoice_ids = set()
added_client_ids = set()

matched_filter = None # To allow invoices to be searched by multiple queries

# Fetch exact matches for Clients
permitted_clients = get_permitted_clients(request)
exact_client = permitted_clients.filter(name__iexact=search_text).first()
if exact_client:
resources["client"].append(
{
"name": f"{exact_client.name} (#{exact_client.id})",
"url": f"{page_mappings['clients']}{exact_client.id}/",
"details": {
"Phone Number": f"{exact_client.phone_number}" if exact_client.phone_number else "N/A",
"Email": f"{exact_client.email}" if exact_client.email else "N/A",
},
}
)
added_client_ids.add(exact_client.id) # To avoid duplicates

# Fetch partial matches for Clients
partial_clients = permitted_clients.filter(name__icontains=search_text)
for client in partial_clients:
if client.id not in added_client_ids: # If current ID is not in already found ID's
resources["client"].append(
{
"name": f"{client.name} (#{client.id})",
"url": f"{page_mappings['clients']}{client.id}/",
"details": {
"Phone Number": f"{client.phone_number}" if client.phone_number else "N/A",
"Email": f"{client.email}" if client.email else "N/A",
},
}
)
added_client_ids.add(client.id) # To avoid duplicates

# Save permitted invoices to variable
permitted_invoices = get_permitted_invoices(request)

if search_text.isdigit():
# Fetch by ID if it's valid ID
exact_invoice = permitted_invoices.filter(id=int(search_text)).first()
if exact_invoice:
resources["invoice"].append(
{
"name": f"{exact_invoice.client_company} (#{exact_invoice.id})",
"url": f"{page_mappings['invoices single']}{exact_invoice.id}",
"details": {
k: v
for k, v in {
"Due Date": exact_invoice.date_due.strftime("%d/%m/%Y") if exact_invoice.date_due else "N/A",
"Total Amount": (
f"{exact_invoice.get_total_price()} {exact_invoice.currency}" if exact_invoice.get_total_price() else "N/A"
),
"Client Name": exact_invoice.client_name,
}.items()
if v not in [None, ""]
},
}
)

else:
# Fetch exact matches for Invoices
exact_invoice = permitted_invoices.filter(client_name__iexact=search_text)
if exact_invoice:
matched_filter = "client_name" # Save used filter
else:
# If no match for client_name, try client_company
exact_invoice = permitted_invoices.filter(client_company__iexact=search_text)
if exact_invoice:
matched_filter = "client_company"
for invoice in exact_invoice:
resources["invoice"].append(
{
"name": f"{invoice.client_name if matched_filter == 'client_name' else invoice.client_company} (#{invoice.id})",
"url": f"{page_mappings['invoices single']}{invoice.id}",
"details": {
k: v
for k, v in {
"Due Date": invoice.date_due.strftime("%d/%m/%Y") if invoice.date_due else "N/A",
"Total Amount": f"{invoice.get_total_price()} {invoice.currency}" if invoice.get_total_price() else "N/A",
"Company" if matched_filter == "client_name" else "Client": (
invoice.client_company if matched_filter == "client_name" else invoice.client_name
),
}.items()
if v is not None
},
}
)
added_invoice_ids.add(invoice.id) # To avoid duplicates

# Fetch partial matches for Invoices
partial_invoices = permitted_invoices.filter(client_name__icontains=search_text)
if partial_invoices:
matched_filter = "client_name" # Save used filter
else:
# If no match for client_name, try client_company
partial_invoices = permitted_invoices.filter(client_company__icontains=search_text)
if partial_invoices:
matched_filter = "client_company"
for invoice in partial_invoices:
if invoice.id not in added_invoice_ids: # If current ID is not in already found ID's
resources["invoice"].append(
{
"name": f"{invoice.client_name if matched_filter == 'client_name' else invoice.client_company} (#{invoice.id})",
"url": f"{page_mappings['invoices single']}{invoice.id}",
"details": {
k: v
for k, v in {
"Due Date": invoice.date_due.strftime("%d/%m/%Y") if invoice.date_due else "N/A",
"Total Amount": f"{invoice.get_total_price()} {invoice.currency}" if invoice.get_total_price() else "N/A",
"Company" if matched_filter == "client_name" else "Client": (
invoice.client_company if matched_filter == "client_name" else invoice.client_name
),
}.items()
if v not in [None, ""]
},
}
)
added_invoice_ids.add(invoice.id) # To avoid duplicates

return render(
request,
"base/topbar/_search_dropdown.html",
{
"services": filtered_services,
"resources": resources,
"resource_count": sum(len(v) for v in resources.values()),
"search": search_text,
},
)
7 changes: 6 additions & 1 deletion backend/core/api/base/urls.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from django.urls import path
from . import modal, notifications, breadcrumbs
from . import modal, notifications, breadcrumbs, global_search

urlpatterns = [
path(
Expand All @@ -24,6 +24,11 @@
name="notifications delete",
),
path("breadcrumbs/refetch/", breadcrumbs.update_breadcrumbs_endpoint, name="breadcrumbs refetch"),
path(
"global_search",
global_search.global_search_endpoint,
name="global_search"
)
]

app_name = "base"
72 changes: 72 additions & 0 deletions frontend/templates/base/topbar/_search_dropdown.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
{% load to_list from strfilters %}
<div class="bg-base-100 shadow flex flex-col rounded-box p-4 overflow-y-auto max-h-[60vh]">
{% if search %}<div class="text-sm break-words">Results for '{{ search }}'</div>{% endif %}
{% if services %}
<div class="text-lg font-semibold mb-4">Services ({{ services | length }})</div>
<hr class="mb-4">
<div class="space-y-4">
{% for service_name, service in services.items %}
<div class="group flex flex-row gap-4 p-4 border border-base-300 rounded-lg transition-all duration-300 hover:shadow-md overflow-hidden">
<div class="flex items-center justify-center w-14 h-14 bg-base-200 rounded-lg text-primary">
<i class="fa {{ service.icon }} text-3xl"></i>
</div>
<div class="flex-1">
<a class="text-lg font-medium link link-primary link-hover"
hx-boost="true"
href="{{ service.url }}">{{ service_name | title }}</a>
<div class="text-md">{{ service.description }}</div>
{% if service.features %}
<div class="max-h-0 overflow-hidden transition-all duration-300 group-hover:max-h-20">
<hr class="my-2">
<span class="text-md font-bold">Features</span>
<div class="flex items-center gap-2 text-sm">
{% for feature_name, url in service.features.items %}
<a hx-boost="true" class="link link-primary link-hover" href="{{ url }}">{{ feature_name }}</a>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% if resource_count %}
<div class="text-lg font-semibold my-4">Resources ({{ resource_count }})</div>
<hr class="mb-4">
<div class="space-y-4">
{% for resource_type, resource_list in resources.items %}
{% for resource in resource_list %}
<div class="group flex flex-row gap-4 p-4 border border-base-300 rounded-lg transition-all duration-300 hover:shadow-md overflow-hidden">
<div class="flex items-center justify-center w-14 h-14 bg-base-200 rounded-lg text-primary">
{% if resource_type == "client" %}
<i class="fa fa-user text-3xl"></i>
{% elif resource_type == "invoice" %}
<i class="fa fa-file-invoice text-3xl"></i>
{% endif %}
</div>
<div class="flex-1">
<a class="text-lg font-medium link link-primary link-hover"
hx-boost="true"
href="{{ resource.url }}">{{ resource.name }}</a>
<div class="text-xl">{{ resource_type | title }}</div>
<div class="max-h-0 overflow-hidden transition-all duration-300 group-hover:max-h-20">
<hr class="my-2">
<div class="flex flex-row gap-2">
{% for detail_name, resource_detail in resource.details.items %}
<div class="flex items-center gap-2 text-sm">
{% with rc='bg-primary,bg-secondary,bg-error,bg-info'|to_list|random %}
<div class="w-2 h-2 {{ rc }} rounded-full"></div>
{% endwith %}
<span>{{ detail_name }}: {{ resource_detail }}</span>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endfor %}
{% endfor %}
</div>
{% endif %}
</div>
22 changes: 22 additions & 0 deletions frontend/templates/base/topbar/_topbar.html
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,28 @@
{# Right Icons #}
{# <div class="flex">#}
{# Profile Picture #}
<div class="dropdown dropdown-left dropdown-bottom">
<div class="relative w-full max-w-xs">
<input class="input input-sm input-bordered pr-8"
name="search"
type="text"
placeholder="Search..."
hx-get="{% url 'api:base:global_search' %}"
hx-target="#global_search"
hx-swap="innerHTML"
hx-trigger="keyup changed delay:500ms, click once, load delay:500ms" />
<button type="button"
onclick='document.querySelector(`input[name="search"]`).value=""'
class="absolute right-2 top-1/2 transform -translate-y-1/2 !text-primary !hover:text-error focus:outline-none text-xs">
</button>
</div>
{# <div tabindex="0" role="button" class="btn m-1">Click</div>#}
<ul tabindex="0"
class="dropdown-content bg-base-100 rounded-box z-[1] !w-[40rem] overflow-hidden p-4"
id="global_search">
</ul>
</div>
<details class="mr-3 dropdown dropdown-end">
<summary class="btn btn-ghost"
hx-get="{% url "api:base:notifications get" %}"
Expand Down
Loading