From 94ee81a7a8514f4bdef81caaef512f14ea098dbe Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Sat, 14 Feb 2026 09:58:18 +0900 Subject: [PATCH 01/43] BE: Speed up section minimize --- .../projectify/workspace/selectors/section.py | 5 +- .../workspace/project_detail/section.html | 170 +++++++++--------- .../workspace/test/views/test_section.py | 6 +- backend/projectify/workspace/views/section.py | 10 +- 4 files changed, 99 insertions(+), 92 deletions(-) diff --git a/backend/projectify/workspace/selectors/section.py b/backend/projectify/workspace/selectors/section.py index 2aed10f62..052554c9b 100644 --- a/backend/projectify/workspace/selectors/section.py +++ b/backend/projectify/workspace/selectors/section.py @@ -18,7 +18,10 @@ "task_set", "task_set__assignee", "task_set__assignee__user", - "task_set__labels", + Prefetch( + "task_set__labels", + queryset=labels_annotate_with_colors(Label.objects.all()), + ), "task_set__subtask_set", Prefetch( "project__workspace__project_set", diff --git a/backend/projectify/workspace/templates/workspace/project_detail/section.html b/backend/projectify/workspace/templates/workspace/project_detail/section.html index 559f1b928..a3027569b 100644 --- a/backend/projectify/workspace/templates/workspace/project_detail/section.html +++ b/backend/projectify/workspace/templates/workspace/project_detail/section.html @@ -1,15 +1,13 @@ -{# SPDX-FileCopyrightText: 2024-2025 JWP Consulting GK #} +{# SPDX-FileCopyrightText: 2024-2026 JWP Consulting GK #} {# SPDX-License-Identifier: AGPL-3.0-or-later #} {% load i18n %} {% load projectify %}
-
+
{% csrf_token %} @@ -40,87 +38,89 @@

{{ section.title }}

- - - {% for task in section.task_set.all %} - - - - + + {% endfor %} + + +
- - #{{ task.number }} - {{ task.title }} - - -
- {% for label in task.labels.all %} -
- {{ label.name }} -
- {% endfor %} -
- -
- {# TODO render sub task progress #} -
- - {# TODO render assignee #} -
- {% if task.assignee %} - {% user_avatar task.assignee %} - {% else %} - - {% endif %} - -
- - -
+ {% endif %}
diff --git a/backend/projectify/workspace/test/views/test_section.py b/backend/projectify/workspace/test/views/test_section.py index 9df2e5a5a..2bbbaaaec 100644 --- a/backend/projectify/workspace/test/views/test_section.py +++ b/backend/projectify/workspace/test/views/test_section.py @@ -207,14 +207,12 @@ def test_section_minimize_toggle( == initial_state ) - with django_assert_num_queries(7): + with django_assert_num_queries(8): response = user_client.post( reverse("dashboard:sections:minimize", args=[section.uuid]), {"minimized": form_value}, ) - - assert response.status_code == 302 - assert isinstance(response, HttpResponseRedirect) + assert response.status_code == 200 section.refresh_from_db() assert ( diff --git a/backend/projectify/workspace/views/section.py b/backend/projectify/workspace/views/section.py index a16e469b6..8354b7b2e 100644 --- a/backend/projectify/workspace/views/section.py +++ b/backend/projectify/workspace/views/section.py @@ -214,7 +214,7 @@ def section_minimize_view( ) -> HttpResponse: """Toggle section minimize state.""" section = section_find_for_user_and_uuid( - user=request.user, section_uuid=section_uuid + user=request.user, section_uuid=section_uuid, qs=SectionDetailQuerySet ) if section is None: raise Http404(_("Section not found for this UUID")) @@ -225,7 +225,13 @@ def section_minimize_view( minimized = form.cleaned_data["minimized"] section_minimize(who=request.user, section=section, minimized=minimized) - return HttpResponseRedirect(section.get_absolute_url()) + setattr(section, "minimized", minimized) + context = { + "section": section, + } + return render( + request, "workspace/project_detail/section.html", context=context + ) class SectionCreate(APIView): From 481d89750051737d10bbba83dda170ea65ef1de3 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Sat, 14 Feb 2026 10:31:50 +0900 Subject: [PATCH 02/43] BE: Optimize SVG icon usage --- backend/projectify/lib/tests/test_views.py | 41 +++++ backend/projectify/lib/views.py | 42 ++++- .../templates/onboarding/assign_task.html | 5 +- .../onboarding/common/form_description.html | 3 +- .../heroicons/REUSE.toml | 0 .../heroicons/archive.svg | 0 .../heroicons/arrow-circle-left.svg | 0 .../heroicons/arrow-circle-right.svg | 0 .../heroicons/arrow-down.svg | 0 .../heroicons/arrow-up.svg | 0 .../heroicons/arrows-expand.svg | 0 .../heroicons/briefcase.svg | 0 .../heroicons/check-circle.svg | 0 .../{templates => static}/heroicons/check.svg | 0 .../heroicons/chevron-down.svg | 0 .../heroicons/chevron-up.svg | 0 .../{templates => static}/heroicons/cog.svg | 0 .../heroicons/dots-horizontal.svg | 0 .../heroicons/dots-vertical.svg | 0 .../heroicons/duplicate.svg | 0 .../heroicons/external_links.svg | 0 .../heroicons/folder.svg | 0 .../heroicons/light-bulb.svg | 0 .../heroicons/pencil.svg | 0 .../{templates => static}/heroicons/plus.svg | 0 .../heroicons/search.svg | 0 .../heroicons/selector.svg | 0 .../heroicons/sort-ascending.svg | 0 .../heroicons/sort-descending.svg | 0 .../heroicons/switch-vertical.svg | 0 .../{templates => static}/heroicons/tag.svg | 0 .../{templates => static}/heroicons/trash.svg | 0 .../{templates => static}/heroicons/user.svg | 0 .../{templates => static}/heroicons/users.svg | 0 .../{templates => static}/heroicons/x.svg | 0 .../projectify/templates/common/footer.html | 173 ++++++------------ .../projectify/templates/dashboard_base.html | 4 +- .../django/forms/widgets/checkbox.html | 3 +- backend/projectify/templatetags/projectify.py | 42 ++++- backend/projectify/urls.py | 4 + .../user/templates/user/sign_up.html | 5 +- .../common/sidebar/project_details.html | 14 +- .../common/sidebar/workspace_details.html | 7 +- .../workspace/project_detail/section.html | 12 +- .../templates/workspace/task_create.html | 2 +- .../workspace/task_create/sub_task.html | 3 +- .../templates/workspace/task_detail.html | 18 +- .../templates/workspace/task_update.html | 5 +- .../workspace/workspace_settings_labels.html | 2 +- .../workspace_settings_projects.html | 2 +- .../workspace_settings_team_members.html | 2 +- 51 files changed, 224 insertions(+), 165 deletions(-) create mode 100644 backend/projectify/lib/tests/test_views.py rename backend/projectify/{templates => static}/heroicons/REUSE.toml (100%) rename backend/projectify/{templates => static}/heroicons/archive.svg (100%) rename backend/projectify/{templates => static}/heroicons/arrow-circle-left.svg (100%) rename backend/projectify/{templates => static}/heroicons/arrow-circle-right.svg (100%) rename backend/projectify/{templates => static}/heroicons/arrow-down.svg (100%) rename backend/projectify/{templates => static}/heroicons/arrow-up.svg (100%) rename backend/projectify/{templates => static}/heroicons/arrows-expand.svg (100%) rename backend/projectify/{templates => static}/heroicons/briefcase.svg (100%) rename backend/projectify/{templates => static}/heroicons/check-circle.svg (100%) rename backend/projectify/{templates => static}/heroicons/check.svg (100%) rename backend/projectify/{templates => static}/heroicons/chevron-down.svg (100%) rename backend/projectify/{templates => static}/heroicons/chevron-up.svg (100%) rename backend/projectify/{templates => static}/heroicons/cog.svg (100%) rename backend/projectify/{templates => static}/heroicons/dots-horizontal.svg (100%) rename backend/projectify/{templates => static}/heroicons/dots-vertical.svg (100%) rename backend/projectify/{templates => static}/heroicons/duplicate.svg (100%) rename backend/projectify/{templates => static}/heroicons/external_links.svg (100%) rename backend/projectify/{templates => static}/heroicons/folder.svg (100%) rename backend/projectify/{templates => static}/heroicons/light-bulb.svg (100%) rename backend/projectify/{templates => static}/heroicons/pencil.svg (100%) rename backend/projectify/{templates => static}/heroicons/plus.svg (100%) rename backend/projectify/{templates => static}/heroicons/search.svg (100%) rename backend/projectify/{templates => static}/heroicons/selector.svg (100%) rename backend/projectify/{templates => static}/heroicons/sort-ascending.svg (100%) rename backend/projectify/{templates => static}/heroicons/sort-descending.svg (100%) rename backend/projectify/{templates => static}/heroicons/switch-vertical.svg (100%) rename backend/projectify/{templates => static}/heroicons/tag.svg (100%) rename backend/projectify/{templates => static}/heroicons/trash.svg (100%) rename backend/projectify/{templates => static}/heroicons/user.svg (100%) rename backend/projectify/{templates => static}/heroicons/users.svg (100%) rename backend/projectify/{templates => static}/heroicons/x.svg (100%) diff --git a/backend/projectify/lib/tests/test_views.py b/backend/projectify/lib/tests/test_views.py new file mode 100644 index 000000000..d71c5e91b --- /dev/null +++ b/backend/projectify/lib/tests/test_views.py @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# SPDX-FileCopyrightText: 2026 JWP Consulting GK +"""Test lib views.""" + +from django.test.client import Client +from django.urls import reverse + +import pytest + + +class TestColoredIconView: + """Test the colored_icon view.""" + + def test_get(self, client: Client) -> None: + """Test getting an icon. Only test for one color.""" + url = reverse( + "colored-icon", + kwargs={"icon": "external_links", "color": "primary"}, + ) + response = client.get(url) + + assert response.status_code == 200 + assert response["Content-Type"] == "image/svg+xml" + assert ' None: + """Test that 404 is returned for invalid icon or color.""" + url = reverse( + "colored-icon", + kwargs={"icon": icon, "color": color}, + ) + assert client.get(url).status_code == 404 diff --git a/backend/projectify/lib/views.py b/backend/projectify/lib/views.py index 7574fbcee..b8f76cb59 100644 --- a/backend/projectify/lib/views.py +++ b/backend/projectify/lib/views.py @@ -1,15 +1,21 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # -# SPDX-FileCopyrightText: 2024 JWP Consulting GK +# SPDX-FileCopyrightText: 2026 JWP Consulting GK """View decorators.""" +import logging +from pathlib import Path from typing import Any, Protocol from django.contrib.auth.decorators import login_required -from django.http import HttpResponse +from django.contrib.staticfiles import finders +from django.http import Http404, HttpRequest, HttpResponse +from django.views.decorators.cache import cache_control from projectify.lib.types import AuthenticatedHttpRequest +logger = logging.getLogger(__name__) + class LoggedInViewP(Protocol): """Django view method that takes an AuthenticatedHttpRequest.""" @@ -30,3 +36,35 @@ def platform_view(func: LoggedInViewP) -> LoggedInViewP: logged in. """ return login_required(func) + + +@cache_control(max_age=3600) +def colored_icon(request: HttpRequest, icon: str, color: str) -> HttpResponse: + """Return a colored SVG icon.""" + del request + # See `const colors` projectify/theme/static_src/tailwind.config.js + color_map = { + "primary": "#2563EB", + "destructive": "#dc2626", + "white": "#ffffff", + } + + match finders.find(f"heroicons/{icon}.svg"): + case list() as paths: + raise ValueError( + f"Tried to look for icon {icon}, got a list of paths {paths}" + ) + case None: + raise Http404(f"Icon '{icon}' not found") + case str() as icon_path: + pass + + if color not in color_map: + raise Http404(f"Missing color '{color}' for icon '{icon}'") + + svg_content = ( + Path(icon_path) + .read_text() + .replace(" {% trans "Learn more about workspace billing settings" %}(Opens in new tab) - {% include "heroicons/external_links.svg" %} + {% icon "external_links" %} {# TODO:: Use django template url tag #} {% trans "Go to workspace billing setting" %}(Opens in new tab) - {% include "heroicons/external_links.svg" %} + {% icon "external_links" %} diff --git a/backend/projectify/onboarding/templates/onboarding/common/form_description.html b/backend/projectify/onboarding/templates/onboarding/common/form_description.html index c42b04070..08a91776d 100644 --- a/backend/projectify/onboarding/templates/onboarding/common/form_description.html +++ b/backend/projectify/onboarding/templates/onboarding/common/form_description.html @@ -1,6 +1,7 @@ {# SPDX-FileCopyrightText: 2025 JWP Consulting GK #} {# SPDX-License-Identifier: AGPL-3.0-or-later #} {% load i18n %} +{% load projectify %}

{% trans title %}

@@ -14,7 +15,7 @@

{% trans title %}

{% trans href_label %}{% trans "(Opens in new tab)" %} - {% include "heroicons/external_links.svg" %} + {% icon "external_links" %} {% else %} -
-

- {% trans "Copyright 2021-2024 JWP Consulting GK" %} -
- {% trans "The Projectify application is free software, and you are welcome to redistribute it under certain conditions; " %}{% trans "see here for details" %} -

-

- {% trans "This version was built on 2025-05-05 from commit 3898a21a on branch feat/remove-fe-storefront-pages committed on 2025-05-05." %} -

-
+
  • {% anchor "help:list" "Help and tips" external=True %}
  • +
  • {% anchor "help:detail" "Keyboard shortcuts" external=True page="keyboard-shortcuts" %}
  • +
  • {% anchor "https://www.jwpconsulting.net/tags/projectify/" "Blog" external=True %}
  • +
  • {% anchor "https://github.com/jwpconsulting/projectify" "GitHub Repository" external=True %}
  • +
  • {% anchor "/download" "Download and install" %}
  • + + +
    +
    {% trans "Company" %}
    +
      +
    • {% anchor "storefront:accessibility" "Accessibility statement" %}
    • +
    • {% anchor "storefront:contact_us" "Contact us" %}
    • +
    • {% anchor "https://www.jwpconsulting.net" "Corporate information" external=True %}
    • +
    +
    +
    +
    {% trans "Security" %}
    +
      +
    • {% anchor "storefront:security:general" "General information" %}
    • +
    • {% anchor "storefront:security:disclose" "Disclosure Policy" %}
    • +
    +
    +
    +
    {% trans "Legal" %}
    +
      +
    • {% anchor "storefront:privacy" "Privacy Policy" %}
    • +
    • {% anchor "storefront:tos" "Terms of Service" %}
    • +
    • {% anchor "storefront:credits" "Credits" %}
    • +
    • {% anchor "storefront:free_software" "Free Software" %}
    • +
    +
    + +
    +

    + {% trans "Copyright 2021-2024 JWP Consulting GK" %} +
    + {% trans "The Projectify application is free software, and you are welcome to redistribute it under certain conditions; " %}{% anchor "/free-software" "see here for details" %} +

    +

    + {% trans "This version was built on 2025-05-05 from commit 3898a21a on branch feat/remove-fe-storefront-pages committed on 2025-05-05." %} +

    +
    diff --git a/backend/projectify/templates/dashboard_base.html b/backend/projectify/templates/dashboard_base.html index 33d025ac6..e7d5aa651 100644 --- a/backend/projectify/templates/dashboard_base.html +++ b/backend/projectify/templates/dashboard_base.html @@ -25,11 +25,11 @@
    {% block dashboard_projects %} diff --git a/backend/projectify/templates/django/forms/widgets/checkbox.html b/backend/projectify/templates/django/forms/widgets/checkbox.html index cd8be740d..a6357658f 100644 --- a/backend/projectify/templates/django/forms/widgets/checkbox.html +++ b/backend/projectify/templates/django/forms/widgets/checkbox.html @@ -1,10 +1,11 @@ {# SPDX-FileCopyrightText: 2025 JWP Consulting GK #} {# SPDX-License-Identifier: AGPL-3.0-or-later #} +{% load projectify %}
    - +
    diff --git a/backend/projectify/templatetags/projectify.py b/backend/projectify/templatetags/projectify.py index 436392b38..8ee78ec85 100644 --- a/backend/projectify/templatetags/projectify.py +++ b/backend/projectify/templatetags/projectify.py @@ -1,12 +1,14 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # -# SPDX-FileCopyrightText: 2024 JWP Consulting GK +# SPDX-FileCopyrightText: 2024-2026 JWP Consulting GK """Shared template tags for Projectify.""" +import logging from typing import Any, Literal, Optional, Union from django import template -from django.template.loader import render_to_string +from django.contrib.staticfiles import finders +from django.templatetags import static from django.urls import NoReverseMatch, reverse from django.utils.html import format_html from django.utils.safestring import SafeText, mark_safe @@ -15,6 +17,8 @@ from projectify.user.models.user import User from projectify.workspace.models.team_member import TeamMember +logger = logging.getLogger(__name__) + register = template.Library() @@ -52,8 +56,11 @@ def anchor( if external: a_extra = mark_safe(' target="_blank"') extra = format_html( - '{text}{svg}', - svg=render_to_string("heroicons/external_links.svg"), + '{text}', + src=reverse( + "colored-icon", + kwargs={"icon": "external_links", "color": "primary"}, + ), text=_("(Opens in new tab)"), ) else: @@ -94,8 +101,9 @@ def action_button( width_class="w-full" if grow else "", color_classes=color_classes[style], icon=format_html( - '
    {icon}
    ', - icon=render_to_string(f"heroicons/{icon}.svg"), + '', + src=static.static(f"heroicons/{icon}.svg"), + text=icon, ) if icon else "", @@ -105,6 +113,28 @@ def action_button( ) +@register.simple_tag +def icon( + icon: str, color: Optional[Literal["primary", "destructive"]] = None +) -> SafeText: + """Return a rendered heroicon SVG file with optional color.""" + static_path = f"heroicons/{icon}.svg" + if not finders.find(static_path): + logger.error("Missing icon '%s'", icon) + return format_html("
    MISSING ICON {}
    ", icon) + + if color: + src = reverse("colored-icon", kwargs={"icon": icon, "color": color}) + else: + src = static.static(static_path) + + return format_html( + '{icon} icon', + src=src, + icon=icon, + ) + + @register.simple_tag def user_avatar( team_member_or_user: Union[None, TeamMember, User], diff --git a/backend/projectify/urls.py b/backend/projectify/urls.py index 71787dac5..b79531e93 100644 --- a/backend/projectify/urls.py +++ b/backend/projectify/urls.py @@ -24,6 +24,7 @@ from django_ratelimit.exceptions import Ratelimited from projectify.lib.settings import get_settings +from projectify.lib.views import colored_icon from projectify.workspace.consumers import ChangeConsumer settings = get_settings() @@ -34,6 +35,9 @@ path("user/", include("projectify.user.urls")), path("workspace/", include("projectify.workspace.urls")), path("corporate/", include("projectify.corporate.urls")), + path( + "icons//.svg", colored_icon, name="colored-icon" + ), ) if settings.ENABLE_DJANGO_FRONTEND: urlpatterns = ( diff --git a/backend/projectify/user/templates/user/sign_up.html b/backend/projectify/user/templates/user/sign_up.html index c7bd04806..5d735c1c6 100644 --- a/backend/projectify/user/templates/user/sign_up.html +++ b/backend/projectify/user/templates/user/sign_up.html @@ -2,6 +2,7 @@ {# SPDX-License-Identifier: AGPL-3.0-or-later #} {% extends "user_base.html" %} {% load i18n %} +{% load projectify %} {% block title %} {% translate "Sign up - Projectify" %} {% endblock title %} @@ -28,7 +29,7 @@

    {% trans "Sign up and start a free trial" %}

    target="_blank"> {% trans "I agree to the Terms of Service" %} {% trans "(Opens in new tab)" %} - {% include "heroicons/external_links.svg" %} + {% icon "external_links" %} {% csrf_token %} diff --git a/backend/projectify/workspace/templates/workspace/common/sidebar/project_details.html b/backend/projectify/workspace/templates/workspace/common/sidebar/project_details.html index 40bfab432..d02d166ce 100644 --- a/backend/projectify/workspace/templates/workspace/common/sidebar/project_details.html +++ b/backend/projectify/workspace/templates/workspace/common/sidebar/project_details.html @@ -7,8 +7,12 @@ href="{% url "dashboard:projects:detail" project_item.uuid %}">
    -
    - {% include "heroicons/folder.svg" %} +
    + {% if project_item == project %} + {% icon "folder" "white" %} + {% else %} + {% icon "folder" %} + {% endif %}
    {{ project_item.title }}
    @@ -18,7 +22,7 @@ {% if workspace %} - {% include "heroicons/plus.svg" %} + {% icon "plus" %} {% translate "Create new project" %} {% endif %} @@ -27,7 +31,7 @@
    -
    {% include "heroicons/user.svg" %}
    +
    {% icon "user" %}

    {% trans "Filter team members" %}

    @@ -38,7 +42,7 @@
    -
    {% include "heroicons/tag.svg" %}
    +
    {% icon "tag" %}

    {% trans "Filter labels" %}

    {{ task_filter_form.filter_by_label }}
    diff --git a/backend/projectify/workspace/templates/workspace/common/sidebar/workspace_details.html b/backend/projectify/workspace/templates/workspace/common/sidebar/workspace_details.html index 8b6d42695..193129226 100644 --- a/backend/projectify/workspace/templates/workspace/common/sidebar/workspace_details.html +++ b/backend/projectify/workspace/templates/workspace/common/sidebar/workspace_details.html @@ -1,23 +1,24 @@ {# SPDX-FileCopyrightText: 2024 JWP Consulting GK #} {# SPDX-License-Identifier: AGPL-3.0-or-later #} {% load i18n %} +{% load projectify %}
    -
    {% include "heroicons/briefcase.svg" %}
    +
    {% icon "briefcase" %}
    {{ workspace.title }}
    @@ -33,7 +33,7 @@

    {{ section.title }}

    data-figma-name="Right side"> - {% include "heroicons/plus.svg" %} + {% icon "plus" color="primary" %} {% translate "Add Task" %}
    @@ -90,7 +90,7 @@

    {{ section.title }}

    aria-label="Move task up" {% if forloop.first %}disabled{% endif %} class="w-8 h-8 p-1.5 rounded-full border border-transparent text-base-content hover:bg-secondary-hover active:bg-disabled-background disabled:bg-transparent disabled:text-disabled"> - {% include "heroicons/chevron-up.svg" %} + {% icon "chevron-up" %} {% csrf_token %}

    ...

    @@ -111,7 +111,7 @@

    {{ section.title }}

    hx-select="#task-actions" href="{% url "dashboard:tasks:actions" task.uuid %}" class="w-8 h-8 p-1.5 rounded-full border border-transparent text-base-content hover:bg-secondary-hover active:bg-disabled-background disabled:bg-transparent disabled:text-disabled"> - {% include "heroicons/dots-horizontal.svg" %} + {% icon "dots-horizontal" %}
    diff --git a/backend/projectify/workspace/templates/workspace/task_create.html b/backend/projectify/workspace/templates/workspace/task_create.html index 4071448d2..ded5a5941 100644 --- a/backend/projectify/workspace/templates/workspace/task_create.html +++ b/backend/projectify/workspace/templates/workspace/task_create.html @@ -18,7 +18,7 @@
    {% include "heroicons/x.svg" %} + aria-label="Go back to section">{% icon "x" %}
    {% include "workspace/task/breadcrumbs.html" with section=section current_page_label=_("New task (currently creating)") current_page_url=request.get_full_path %}
    diff --git a/backend/projectify/workspace/templates/workspace/task_create/sub_task.html b/backend/projectify/workspace/templates/workspace/task_create/sub_task.html index f425df3b4..9a60f4326 100644 --- a/backend/projectify/workspace/templates/workspace/task_create/sub_task.html +++ b/backend/projectify/workspace/templates/workspace/task_create/sub_task.html @@ -1,6 +1,7 @@ {# SPDX-License-Identifier: AGPL-3.0-or-later #} {# SPDX-FileCopyrightText: 2024-2025 JWP Consulting GK #} {% load i18n %} +{% load projectify %} {% if empty_formset %}
    @@ -29,7 +30,7 @@ hx-swap="beforeend" hx-get="{% url 'dashboard:tasks:create-task-sub-task' formset_total %}" class="w-full text-tertiary-content hover:text-tertiary-content-hover active:bg-tertiary-pressed active:text-tertiary-content-hover text-base flex min-w-max flex-row justify-center gap-2 rounded-lg px-4 py-2 font-bold disabled:bg-transparent disabled:text-disabled-content"> -
    {% include "heroicons/plus.svg" %}
    +
    {% icon "plus" %}
    {% translate "Add sub task" %}
    diff --git a/backend/projectify/workspace/templates/workspace/task_detail.html b/backend/projectify/workspace/templates/workspace/task_detail.html index 2d3f5f76e..56a035998 100644 --- a/backend/projectify/workspace/templates/workspace/task_detail.html +++ b/backend/projectify/workspace/templates/workspace/task_detail.html @@ -12,7 +12,7 @@
    {% include "heroicons/x.svg" %} + aria-label="{% trans 'Go back to section' %}">{% icon "x" %}
    {% include "workspace/task/breadcrumbs.html" with section=task.section task=task %}
    @@ -27,7 +27,7 @@ - {% include "heroicons/dots-vertical.svg" %} + {% icon "dots-vertical" %}
    @@ -42,7 +42,7 @@ {{ task.title }} -
    {% include "heroicons/pencil.svg" %}
    +
    {% icon "pencil" %}
    @@ -59,7 +59,7 @@
    {{ task.assignee|default:"Assigned to nobody" }}
    -
    {% include "heroicons/pencil.svg" %}
    +
    {% icon "pencil" %}
    @@ -80,7 +80,7 @@ {% endfor %} -
    {% include "heroicons/pencil.svg" %}
    +
    {% icon "pencil" %}
    @@ -93,7 +93,7 @@ {{ task.due_date.date.isoformat }} -
    {% include "heroicons/pencil.svg" %}
    +
    {% icon "pencil" %}
    @@ -105,7 +105,7 @@ @@ -120,7 +120,7 @@

    {% trans "Sub tasks" %}

    -
    {% include "heroicons/pencil.svg" %}
    +
    {% icon "pencil" %}
    @@ -162,7 +162,7 @@

    {% trans "Sub tasks" %}

    readonly="true"> -
    {% include "heroicons/pencil.svg" %}
    +
    {% icon "pencil" %}
    diff --git a/backend/projectify/workspace/templates/workspace/task_update.html b/backend/projectify/workspace/templates/workspace/task_update.html index 58c70fa47..c576768fa 100644 --- a/backend/projectify/workspace/templates/workspace/task_update.html +++ b/backend/projectify/workspace/templates/workspace/task_update.html @@ -3,6 +3,7 @@ {% extends "dashboard_base.html" %} {% load static %} {% load i18n %} +{% load projectify %} {% block title %} {% translate "Editing task - Projectify" %} {% endblock title %} @@ -18,7 +19,7 @@
    {% include "heroicons/x.svg" %} + aria-label="Go back to section">{% icon "x" %}
    {% include "workspace/task/breadcrumbs.html" with section=task.section task=task %} @@ -43,7 +44,7 @@

    Sub tasks

    name="action" value="add_sub_task" class="w-full text-tertiary-content hover:text-tertiary-content-hover active:bg-tertiary-pressed active:text-tertiary-content-hover text-base flex min-w-max flex-row justify-center gap-2 rounded-lg px-4 py-2 font-bold disabled:bg-transparent disabled:text-disabled-content"> -
    {% include "heroicons/plus.svg" %}
    +
    {% icon "plus" %}
    {% translate "Add sub task" %} diff --git a/backend/projectify/workspace/templates/workspace/workspace_settings_labels.html b/backend/projectify/workspace/templates/workspace/workspace_settings_labels.html index 1da5f4bbd..2f084fc0b 100644 --- a/backend/projectify/workspace/templates/workspace/workspace_settings_labels.html +++ b/backend/projectify/workspace/templates/workspace/workspace_settings_labels.html @@ -27,7 +27,7 @@

    {% translate "Labels" %}

    {{ label.name }} -
    {% include "heroicons/pencil.svg" %}
    +
    {% icon "pencil" %}
    {% else %} {{ label.name }} diff --git a/backend/projectify/workspace/templates/workspace/workspace_settings_projects.html b/backend/projectify/workspace/templates/workspace/workspace_settings_projects.html index f85797540..a0e8f9870 100644 --- a/backend/projectify/workspace/templates/workspace/workspace_settings_projects.html +++ b/backend/projectify/workspace/templates/workspace/workspace_settings_projects.html @@ -29,7 +29,7 @@ {{ project }} -
    {% include "heroicons/pencil.svg" %}
    +
    {% icon "pencil" %}
    {% else %} {{ project }} diff --git a/backend/projectify/workspace/templates/workspace/workspace_settings_team_members.html b/backend/projectify/workspace/templates/workspace/workspace_settings_team_members.html index d14396627..4c361a66e 100644 --- a/backend/projectify/workspace/templates/workspace/workspace_settings_team_members.html +++ b/backend/projectify/workspace/templates/workspace/workspace_settings_team_members.html @@ -56,7 +56,7 @@

    {% trans "Team members" %}

    {{ team_member.get_role_display }} From ae836ced83d25a5c644f3e241cda3ccfaf1926b4 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Sat, 14 Feb 2026 11:34:29 +0900 Subject: [PATCH 03/43] BE: Remove old comments --- .../workspace/templates/workspace/task_detail.html | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/backend/projectify/workspace/templates/workspace/task_detail.html b/backend/projectify/workspace/templates/workspace/task_detail.html index 56a035998..49aed7d1f 100644 --- a/backend/projectify/workspace/templates/workspace/task_detail.html +++ b/backend/projectify/workspace/templates/workspace/task_detail.html @@ -22,7 +22,7 @@ {% trans "Edit" %} - + {% endif %} -
    @@ -45,7 +44,6 @@
    {% icon "pencil" %}
    - @@ -62,7 +60,6 @@
    {% icon "pencil" %}
    - @@ -75,7 +72,6 @@ - {% endfor %} @@ -83,7 +79,6 @@
    {% icon "pencil" %}
    - @@ -96,7 +91,6 @@
    {% icon "pencil" %}
    - @@ -108,7 +102,6 @@
    {% icon "pencil" %}
    - @@ -135,7 +128,6 @@

    {% trans "Sub tasks" %}

    {% endif %} -
    {% for sub_task in task.subtask_set.all %}
    @@ -148,7 +140,6 @@

    {% trans "Sub tasks" %}

    checked="{{ sub_task.done|yesno:"true,false" }}" disabled="">
    -
    @@ -166,11 +157,9 @@

    {% trans "Sub tasks" %}

    -
    - {% endfor %} From 694dcf11656ad6c0fc5c79d75ecde1ac93c633e6 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Sat, 14 Feb 2026 12:05:23 +0900 Subject: [PATCH 04/43] BE: Show sub task progress in project ashboard --- .../projectify/static/heroicons/view-list.svg | 3 + backend/projectify/templatetags/projectify.py | 2 +- .../projectify/workspace/selectors/section.py | 18 ++- .../workspace/project_detail/section.html | 110 +++++++++--------- 4 files changed, 71 insertions(+), 62 deletions(-) create mode 100644 backend/projectify/static/heroicons/view-list.svg diff --git a/backend/projectify/static/heroicons/view-list.svg b/backend/projectify/static/heroicons/view-list.svg new file mode 100644 index 000000000..6be9cb56e --- /dev/null +++ b/backend/projectify/static/heroicons/view-list.svg @@ -0,0 +1,3 @@ + diff --git a/backend/projectify/templatetags/projectify.py b/backend/projectify/templatetags/projectify.py index 8ee78ec85..bb6b438fd 100644 --- a/backend/projectify/templatetags/projectify.py +++ b/backend/projectify/templatetags/projectify.py @@ -27,7 +27,7 @@ def percent(value: Optional[float]) -> Optional[str]: """Format value as percentage.""" if not value: return None - return f"{value:3.0%}" + return _("{sub_task_done} %").format(sub_task_done=round(value * 100)) @register.simple_tag diff --git a/backend/projectify/workspace/selectors/section.py b/backend/projectify/workspace/selectors/section.py index 052554c9b..7d4feb677 100644 --- a/backend/projectify/workspace/selectors/section.py +++ b/backend/projectify/workspace/selectors/section.py @@ -1,21 +1,33 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # -# SPDX-FileCopyrightText: 2023 JWP Consulting GK +# SPDX-FileCopyrightText: 2023-2026 JWP Consulting GK """Section selectors.""" from typing import Optional from uuid import UUID -from django.db.models import Prefetch, QuerySet +from django.db.models import Count, Prefetch, Q, QuerySet +from django.db.models.functions import NullIf from projectify.user.models import User from projectify.workspace.models.label import Label from projectify.workspace.models.project import Project from projectify.workspace.models.section import Section +from projectify.workspace.models.task import Task from projectify.workspace.selectors.labels import labels_annotate_with_colors SectionDetailQuerySet = Section.objects.prefetch_related( - "task_set", + Prefetch( + "task_set", + queryset=Task.objects.annotate( + sub_task_progress=Count( + "subtask", + filter=Q(subtask__done=True), + ) + * 1.0 + / NullIf(Count("subtask"), 0), + ).order_by("_order"), + ), "task_set__assignee", "task_set__assignee__user", Prefetch( diff --git a/backend/projectify/workspace/templates/workspace/project_detail/section.html b/backend/projectify/workspace/templates/workspace/project_detail/section.html index 62b172439..b3358307f 100644 --- a/backend/projectify/workspace/templates/workspace/project_detail/section.html +++ b/backend/projectify/workspace/templates/workspace/project_detail/section.html @@ -39,7 +39,7 @@

    {{ section.title }}

    {% if not section.minimized %} -
    +
    {% for task in section.task_set.all %} @@ -50,71 +50,65 @@

    {{ section.title }}

    {{ task.title }} - - From 4e61fea6b8e9595508ab3bc119825864d373c91f Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Sat, 14 Feb 2026 16:11:20 +0900 Subject: [PATCH 05/43] BE: Add sub task empty state for task views Mark more text for translation, too --- .../workspace/templates/workspace/task_create.html | 8 ++++++-- .../templates/workspace/task_create/sub_task.html | 5 +++-- .../workspace/templates/workspace/task_detail.html | 7 +++++-- .../workspace/templates/workspace/task_update.html | 10 +++++++--- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/backend/projectify/workspace/templates/workspace/task_create.html b/backend/projectify/workspace/templates/workspace/task_create.html index ded5a5941..a759c38c0 100644 --- a/backend/projectify/workspace/templates/workspace/task_create.html +++ b/backend/projectify/workspace/templates/workspace/task_create.html @@ -18,7 +18,7 @@
    {% icon "x" %} + aria-label="{% translate "Go back to section" %}">{% icon "x" %}
    {% include "workspace/task/breadcrumbs.html" with section=section current_page_label=_("New task (currently creating)") current_page_url=request.get_full_path %} @@ -40,7 +40,11 @@

    {% translate "Sub tasks" %}

    {% include "workspace/task_create/sub_task.html" with formset_html=formset formset_total=0 %} -
    +
    +

    + {% blocktrans %}You have not added any sub tasks yet. You can add a sub task by clicking the Add sub task button.{% endblocktrans %} +

    +
    {{ formset.management_form }} {% csrf_token %} diff --git a/backend/projectify/workspace/templates/workspace/task_create/sub_task.html b/backend/projectify/workspace/templates/workspace/task_create/sub_task.html index 9a60f4326..2f0e23f7f 100644 --- a/backend/projectify/workspace/templates/workspace/task_create/sub_task.html +++ b/backend/projectify/workspace/templates/workspace/task_create/sub_task.html @@ -20,17 +20,18 @@ name="form-TOTAL_FORMS" value="{{ formset_total }}" id="id_form-TOTAL_FORMS"> +
    {% endif %}
    diff --git a/backend/projectify/workspace/templates/workspace/task_detail.html b/backend/projectify/workspace/templates/workspace/task_detail.html index 49aed7d1f..d7f6a0103 100644 --- a/backend/projectify/workspace/templates/workspace/task_detail.html +++ b/backend/projectify/workspace/templates/workspace/task_detail.html @@ -54,7 +54,7 @@ type="button" disabled=""> {% user_avatar task.assignee %} -
    {{ task.assignee|default:"Assigned to nobody" }}
    +
    {{ task.assignee|default:"No team member assigned" }}
    {% icon "pencil" %}
    @@ -106,7 +106,6 @@
    -
    - {% for label in task.labels.all %} -
    - {{ label.name }} -
    - {% endfor %} -
    - +
    + {% for label in task.labels.all %} +
    + {{ label.name }} +
    + {% endfor %}
    - {# TODO render sub task progress #} -
    - - {# TODO render assignee #} -
    +
    + {% if task.sub_task_progress %} +
    +
    {% icon "view-list" "primary" %}
    +
    {{ task.sub_task_progress|percent }}
    +
    + {% endif %} +
    {% if task.assignee %} {% user_avatar task.assignee %} {% else %} {% endif %} - -
    -
    - - - {% csrf_token %} -

    ...

    -
    - - - {% icon "dots-horizontal" %} - - -
    +
    +
    +
    + + + {% csrf_token %} +

    ...

    +
    + + {% icon "dots-horizontal" %} +
    -
    @@ -160,6 +159,10 @@

    {% trans "Sub tasks" %}

    + {% empty %} +

    + {% blocktrans %}This task has no sub tasks. You can add sub tasks by going to the task edit screen and clicking the Add sub task button from there.{% endblocktrans %} +

    {% endfor %}
    diff --git a/backend/projectify/workspace/templates/workspace/task_update.html b/backend/projectify/workspace/templates/workspace/task_update.html index c576768fa..352acf0bf 100644 --- a/backend/projectify/workspace/templates/workspace/task_update.html +++ b/backend/projectify/workspace/templates/workspace/task_update.html @@ -19,7 +19,7 @@
    {% icon "x" %} + aria-label="{% translate "Go back to section" %}">{% icon "x" %}
    {% include "workspace/task/breadcrumbs.html" with section=task.section task=task %} @@ -37,14 +37,14 @@
    -

    Sub tasks

    +

    {% translate "Sub tasks" %}

    @@ -73,6 +73,10 @@

    Sub tasks

    + {% empty %} +

    + {% blocktrans %}You have not added any sub tasks yet. You can add a sub task by clicking the Add sub task button.{% endblocktrans %} +

    {% endfor %} From 389aed6fa29d8a26661ea3fb0200fb1a8a64ed8b Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Sat, 14 Feb 2026 16:20:45 +0900 Subject: [PATCH 06/43] BE: Fix task view translations --- backend/projectify/workspace/views/task.py | 32 +++++++++++++++++----- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/backend/projectify/workspace/views/task.py b/backend/projectify/workspace/views/task.py index 3c84d34ab..846dd51e4 100644 --- a/backend/projectify/workspace/views/task.py +++ b/backend/projectify/workspace/views/task.py @@ -92,10 +92,12 @@ class TaskCreateForm(forms.Form): ) due_date = forms.DateTimeField( required=False, + label=_("Due date"), widget=forms.DateTimeInput(attrs={"type": "date"}), ) description = forms.CharField( required=False, + label=_("Description"), widget=forms.Textarea( attrs={"placeholder": _("Enter a description for your task")} ), @@ -111,6 +113,7 @@ def __init__(self, workspace: Workspace, *args: Any, **kwargs: Any): self.fields["assignee"] = forms.ModelChoiceField( required=False, blank=True, + label=_("Assignee"), queryset=workspace.teammember_set.all(), widget=assignee_widget, to_field_name="uuid", @@ -124,6 +127,7 @@ def __init__(self, workspace: Workspace, *args: Any, **kwargs: Any): self.fields["labels"] = forms.ModelMultipleChoiceField( required=False, blank=True, + label=_("Labels"), queryset=workspace.label_set.all(), widget=labels_widget, to_field_name="uuid", @@ -137,8 +141,8 @@ def __init__(self, workspace: Workspace, *args: Any, **kwargs: Any): class TaskCreateSubTaskForm(forms.Form): """Form for creating sub tasks as part of task creation.""" - title = forms.CharField() - done = forms.BooleanField(required=False) + title = forms.CharField(label=_("Sub task title")) + done = forms.BooleanField(required=False, label=_("Done")) TaskCreateSubTaskForms = forms.formset_factory(TaskCreateSubTaskForm, extra=0) # type: ignore[type-var] @@ -147,8 +151,8 @@ class TaskCreateSubTaskForm(forms.Form): class TaskUpdateSubTaskForm(forms.Form): """Form for creating sub tasks as part of task creation.""" - title = forms.CharField(required=False) - done = forms.BooleanField(required=False) + title = forms.CharField(required=False, label=_("Sub task title")) + done = forms.BooleanField(required=False, label=_("Done")) uuid = forms.UUIDField(required=False, widget=forms.HiddenInput) delete = forms.BooleanField( required=False, @@ -246,7 +250,9 @@ def task_create( case "create": return redirect(section.get_absolute_url()) case action: - raise BadRequest(_("Invalid action: {}").format(action)) + raise BadRequest( + _("Invalid action: {action}").format(action=action) + ) @platform_view @@ -258,7 +264,11 @@ def task_detail( who=request.user, task_uuid=task_uuid, qs=TaskDetailQuerySet ) if task is None: - raise Http404(_(f"Could not find task with uuid {task_uuid}")) + raise Http404( + _("Could not find task with uuid {task_uuid}").format( + task_uuid=task_uuid + ) + ) context = { "task": task, "project": task.section.project, @@ -282,10 +292,12 @@ class TaskUpdateForm(forms.Form): ) due_date = forms.DateTimeField( required=False, + label=_("Due date"), widget=forms.DateTimeInput(attrs={"type": "date"}), ) description = forms.CharField( required=False, + label=_("Description"), widget=forms.Textarea( attrs={"placeholder": _("Enter a description for your task")} ), @@ -307,6 +319,7 @@ def __init__( self.fields["assignee"] = forms.ModelChoiceField( required=False, blank=True, + label=_("Assignee"), queryset=workspace.teammember_set.all(), widget=assignee_widget, to_field_name="uuid", @@ -320,6 +333,7 @@ def __init__( self.fields["labels"] = forms.ModelMultipleChoiceField( required=False, blank=True, + label=_("Labels"), queryset=workspace.label_set.all(), widget=labels_widget, to_field_name="uuid", @@ -367,7 +381,11 @@ def task_update_view( who=request.user, task_uuid=task_uuid, qs=TaskDetailQuerySet ) if task is None: - raise Http404(_(f"Could not find task with uuid {task_uuid}")) + raise Http404( + _("Could not find task with uuid {task_uuid}").format( + task_uuid=task_uuid + ) + ) focus_field = request.GET.get("focus", None) context: dict[str, Any] = { From ae077f5a71435ee873856cb255193598c332385a Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Sat, 14 Feb 2026 16:27:36 +0900 Subject: [PATCH 07/43] BE: Improve onboarding screens --- .../templates/onboarding/about_you.html | 2 +- .../templates/onboarding/assign_task.html | 43 ++++++++----------- .../onboarding/common/form_description.html | 25 +++-------- .../onboarding/common/step_counter.html | 2 +- .../templates/onboarding/new_project.html | 6 +-- .../templates/onboarding/new_task.html | 2 +- .../templates/onboarding/welcome.html | 2 +- 7 files changed, 31 insertions(+), 51 deletions(-) diff --git a/backend/projectify/onboarding/templates/onboarding/about_you.html b/backend/projectify/onboarding/templates/onboarding/about_you.html index 9976fe44c..b5ace6b4a 100644 --- a/backend/projectify/onboarding/templates/onboarding/about_you.html +++ b/backend/projectify/onboarding/templates/onboarding/about_you.html @@ -9,7 +9,7 @@
    {% csrf_token %} - {% include "onboarding/common/form_description.html" with title="About you" text_1="Tell us your preferred name. You can also keep it empty and continue by clicking the button below." %} + {% include "onboarding/common/form_description.html" with title=_("About you") text_1=_("Tell us your preferred name. You can also keep it empty and continue by clicking the button below.") %}
    {{ form }}
    diff --git a/backend/projectify/onboarding/templates/onboarding/assign_task.html b/backend/projectify/onboarding/templates/onboarding/assign_task.html index c70f29de3..58253398a 100644 --- a/backend/projectify/onboarding/templates/onboarding/assign_task.html +++ b/backend/projectify/onboarding/templates/onboarding/assign_task.html @@ -14,34 +14,25 @@

    {% blocktrans %}Task "{{task}}" has been assigned to you!{% endblocktrans %}

    -

    {% trans "You’re all set!" %}

    +

    {% trans "You're all set!" %}

    {% trans "If you wish to add new team members to your workspace, please go to the workspace settings menu next to your workspace name." %}

    - {% trans "Learn more about workspace billing settings" %}(Opens in new tab) - {% icon "external_links" %} - - {# TODO:: Use django template url tag #} - {% trans "Go to workspace billing setting" %}(Opens in new tab) - {% icon "external_links" %} - -
    - -
    -
    {{ form }}
    -
    -
    -
    - {% trans "Get started" %} +

    {% anchor "help:detail" _("Learn more about workspace billing settings") external=True page="billing" %}

    +

    {% anchor "dashboard:billing" _("Go to workspace billing settings") workspace_uuid=workspace.uuid %}

    +
    +
    +
    +
    {{ form }}
    +
    + + {% include "onboarding/common/step_counter.html" with step=5 step_count="5" %} + + - -{% include "onboarding/common/step_counter.html" with step=5 step_count="5" %} - - {% endblock onboarding_content %} diff --git a/backend/projectify/onboarding/templates/onboarding/common/form_description.html b/backend/projectify/onboarding/templates/onboarding/common/form_description.html index 08a91776d..8c99ac6f6 100644 --- a/backend/projectify/onboarding/templates/onboarding/common/form_description.html +++ b/backend/projectify/onboarding/templates/onboarding/common/form_description.html @@ -3,25 +3,14 @@ {% load i18n %} {% load projectify %}
    -

    {% trans title %}

    +

    {{ title }}

    -

    {% trans text_1 %}

    - {% if text_2 %} -

    {% trans text_2 %}

    - {% endif %} +

    {{ text_1 }}

    + {% if text_2 %}

    {{ text_2 }}

    {% endif %} {% if href %} - {% if target_blank %} - {% trans href_label %}{% trans "(Opens in new tab)" %} - {% icon "external_links" %} - - {% else %} - {% trans href_label %} - {% endif %} - {% endif %} -
    -
    +

    {% anchor href href_label external=target_blank %}

    + {% endif %} +
    + diff --git a/backend/projectify/onboarding/templates/onboarding/common/step_counter.html b/backend/projectify/onboarding/templates/onboarding/common/step_counter.html index 83bb6cded..9fbbc6ae3 100644 --- a/backend/projectify/onboarding/templates/onboarding/common/step_counter.html +++ b/backend/projectify/onboarding/templates/onboarding/common/step_counter.html @@ -4,7 +4,7 @@ {% with ''|center:step_count as range %} {% for _ in range %} {% if forloop.counter <= step %} -
  • +
  • {% else %}
  • {% endif %} diff --git a/backend/projectify/onboarding/templates/onboarding/new_project.html b/backend/projectify/onboarding/templates/onboarding/new_project.html index 148070e43..a7b5b5ea5 100644 --- a/backend/projectify/onboarding/templates/onboarding/new_project.html +++ b/backend/projectify/onboarding/templates/onboarding/new_project.html @@ -18,11 +18,11 @@

    {% trans "Add your first proje

    {% blocktrans %}Continue adding task to "{{ project }}"{% endblocktrans %} + class="text-primary underline hover:text-primary-hover active:text-primary-pressed text-lg">{% blocktrans %}Continue adding a task to "{{ project }}"{% endblocktrans %}

    {% else %} -

    {% trans "You can create unlimited project per workspace." %}

    -

    {% trans "They help you to focus on different projects you may be working on." %}

    +

    {% trans "You can create unlimited projects per workspace." %}

    +

    {% trans "Projects help you to focus on different projects you may be working on." %}

    {% endif %} diff --git a/backend/projectify/onboarding/templates/onboarding/new_task.html b/backend/projectify/onboarding/templates/onboarding/new_task.html index 642c2a38f..70051b6cc 100644 --- a/backend/projectify/onboarding/templates/onboarding/new_task.html +++ b/backend/projectify/onboarding/templates/onboarding/new_task.html @@ -14,7 +14,7 @@

    {% trans "What is a task you

    {% if section %} - {% blocktrans %}It looks like you already have a section called "{{section}}". We will now create a task and place it into that section.{% endblocktrans %} + {% blocktrans %}It looks like you already have a section called "{{section}}". We will now create a task and place it into that section.{% endblocktrans %} {% else %} {% blocktrans %}This task will be placed in a section called "{{section_title}}".{% endblocktrans %} {% endif %} diff --git a/backend/projectify/onboarding/templates/onboarding/welcome.html b/backend/projectify/onboarding/templates/onboarding/welcome.html index b5aef160a..cb1e38ced 100644 --- a/backend/projectify/onboarding/templates/onboarding/welcome.html +++ b/backend/projectify/onboarding/templates/onboarding/welcome.html @@ -7,7 +7,7 @@ {% endblock title %} {% block onboarding_content %} - {% include "onboarding/common/form_description.html" with title="Welcome" text_1="In the following steps you will create a new workspace and create your first task." text_2="You can use the workspace in trial mode at the beginning, and upgrade to a paid version anytime by going to the workspace settings." target_blank="True" href="/help/quota#quotas-for-trial-workspaces" href_label="Learn more about features available during trial mode" %} + {% include "onboarding/common/form_description.html" with title=_("Welcome") text_1=_("In the following steps you will create a new workspace and create your first task.") text_2=_("You can use the workspace in trial mode at the beginning, and upgrade to a paid version anytime by going to the workspace settings.") target_blank="True" href="/help/quota#quotas-for-trial-workspaces" href_label=_("Learn more about features available during trial mode") %}

    Date: Sat, 14 Feb 2026 16:40:44 +0900 Subject: [PATCH 08/43] BE: Fix missing task assignee in onboarding --- backend/projectify/onboarding/views.py | 77 +++++++++++++++++--------- 1 file changed, 50 insertions(+), 27 deletions(-) diff --git a/backend/projectify/onboarding/views.py b/backend/projectify/onboarding/views.py index d8ac30076..dd7f12a19 100644 --- a/backend/projectify/onboarding/views.py +++ b/backend/projectify/onboarding/views.py @@ -3,6 +3,7 @@ # SPDX-FileCopyrightText: 2025 JWP Consulting GK """Onboarding Views.""" +from typing import Any from uuid import UUID from django import forms @@ -24,6 +25,9 @@ project_find_by_workspace_uuid, ) from projectify.workspace.selectors.task import task_find_by_task_uuid +from projectify.workspace.selectors.team_member import ( + team_member_find_for_workspace, +) from projectify.workspace.selectors.workspace import ( workspace_find_by_workspace_uuid, workspace_find_for_user, @@ -31,7 +35,10 @@ from projectify.workspace.services.label import label_create from projectify.workspace.services.project import project_create from projectify.workspace.services.section import section_create -from projectify.workspace.services.task import task_create, task_update_nested +from projectify.workspace.services.task import ( + task_create_nested, + task_update_nested, +) from projectify.workspace.services.workspace import workspace_create @@ -216,35 +223,50 @@ def new_task( section_title = _("To do") if section: section_title = section.title - - if request.method == "POST": - if section is None: - # Create a section - section = section_create( - who=request.user, - title=section_title, - description=None, - project=project, - ) - # Create a task - form = TaskForm(request.POST) - if form.is_valid(): - task = task_create( - who=request.user, - section=section, - title=form.cleaned_data["title"], - ) - return redirect( - reverse("onboarding:new_label", args=[str(task.uuid)]) - ) - else: - form = TaskForm() - - context = { - "form": form, + context: dict[str, Any] = { "section": section, "section_title": section_title, } + + match request.method: + case "GET": + return render( + request, + "onboarding/new_task.html", + {**context, "form": TaskForm()}, + ) + case "POST": + pass + case method: + raise ValueError(f"Should not have hit method {method}") + + if section is None: + section = section_create( + who=request.user, + title=section_title, + description=None, + project=project, + ) + team_member = team_member_find_for_workspace( + user=request.user, workspace=project.workspace + ) + if not team_member: + raise RuntimeError( + f"No team_member found for current user in workspace {project.workspace.uuid}" + ) + + form = TaskForm(request.POST) + if form.is_valid(): + task = task_create_nested( + who=request.user, + section=section, + title=form.cleaned_data["title"], + assignee=team_member, + labels=[], + sub_tasks={"create_sub_tasks": [], "update_sub_tasks": []}, + ) + return redirect(reverse("onboarding:new_label", args=[str(task.uuid)])) + context = {**context, "form": form} return render(request, "onboarding/new_task.html", context) @@ -296,6 +318,7 @@ def new_label( task=task, title=task.title, labels=[label], + assignee=task.assignee, ) return redirect( From 6c4e274c7cc0ed6ffd531e2af762a3ec6ce558f9 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Sat, 14 Feb 2026 17:51:23 +0900 Subject: [PATCH 09/43] BE: Implement project list minimize --- .../projectify/templates/dashboard_base.html | 17 +--- .../projectify/workspace/dashboard_urls.py | 6 ++ .../0070_teammember_minimized_project_list.py | 25 ++++++ .../workspace/models/team_member.py | 8 +- .../workspace/services/team_member.py | 28 ++++-- .../common/sidebar/project_details.html | 73 ++++++++++----- .../test/services/test_team_member.py | 29 +++++- .../workspace/test/views/test_workspace.py | 90 ++++++++++++++++++- backend/projectify/workspace/views/project.py | 40 +++++++++ backend/projectify/workspace/views/task.py | 54 ++++++----- .../projectify/workspace/views/workspace.py | 74 ++++++++++++--- 11 files changed, 366 insertions(+), 78 deletions(-) create mode 100644 backend/projectify/workspace/migrations/0070_teammember_minimized_project_list.py diff --git a/backend/projectify/templates/dashboard_base.html b/backend/projectify/templates/dashboard_base.html index e7d5aa651..64e5f5edd 100644 --- a/backend/projectify/templates/dashboard_base.html +++ b/backend/projectify/templates/dashboard_base.html @@ -22,20 +22,9 @@ {% anchor href=trial_workspace_help label=_("Learn more") external=True %}

    - -
    - {% block dashboard_projects %} - {% include "workspace/common/sidebar/project_details.html" %} - {% endblock dashboard_projects %} -
    + {% block dashboard_projects %} + {% include "workspace/common/sidebar/project_details.html" %} + {% endblock dashboard_projects %}
    diff --git a/backend/projectify/workspace/dashboard_urls.py b/backend/projectify/workspace/dashboard_urls.py index cf263fe5f..8a05a31e8 100644 --- a/backend/projectify/workspace/dashboard_urls.py +++ b/backend/projectify/workspace/dashboard_urls.py @@ -33,6 +33,7 @@ task_update_view, ) from projectify.workspace.views.workspace import ( + workspace_minimize_project_list, workspace_settings_billing, workspace_settings_billing_edit, workspace_settings_edit_label, @@ -62,6 +63,11 @@ project_create_view, name="create-project", ), + path( + "/minimize-project-list", + workspace_minimize_project_list, + name="minimize-project-list", + ), # Settings path( "/settings", diff --git a/backend/projectify/workspace/migrations/0070_teammember_minimized_project_list.py b/backend/projectify/workspace/migrations/0070_teammember_minimized_project_list.py new file mode 100644 index 000000000..1594ba1bf --- /dev/null +++ b/backend/projectify/workspace/migrations/0070_teammember_minimized_project_list.py @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# SPDX-FileCopyrightText: 2026 JWP Consulting GK +"""Add minimized_project_list field to TeamMember model.""" + +from django.db import migrations, models + + +class Migration(migrations.Migration): + """Run the migration.""" + + dependencies = [ + ("workspace", "0069_userpreferences"), + ] + + operations = [ + migrations.AddField( + model_name="teammember", + name="minimized_project_list", + field=models.BooleanField( + default=False, + help_text="Whether this team member has minimized the project list in this workspace", + ), + ), + ] diff --git a/backend/projectify/workspace/models/team_member.py b/backend/projectify/workspace/models/team_member.py index c09b293db..9f91d0935 100644 --- a/backend/projectify/workspace/models/team_member.py +++ b/backend/projectify/workspace/models/team_member.py @@ -1,6 +1,6 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # -# SPDX-FileCopyrightText: 2023 JWP Consulting GK +# SPDX-FileCopyrightText: 2023,2026 JWP Consulting GK """Team member models.""" import uuid @@ -64,6 +64,12 @@ class TeamMember(BaseModel): "Timestamp when this team member last visited this workspace" ), ) + minimized_project_list = models.BooleanField( + default=False, + help_text=_( + "Whether this team member has minimized the project list in this workspace" + ), + ) if TYPE_CHECKING: # Related diff --git a/backend/projectify/workspace/services/team_member.py b/backend/projectify/workspace/services/team_member.py index 91bef63cb..56dbfe2a9 100644 --- a/backend/projectify/workspace/services/team_member.py +++ b/backend/projectify/workspace/services/team_member.py @@ -1,6 +1,6 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # -# SPDX-FileCopyrightText: 2023 JWP Consulting GK +# SPDX-FileCopyrightText: 2023,2026 JWP Consulting GK """Team member services.""" from typing import Optional @@ -75,9 +75,10 @@ def team_member_visit_workspace(*, user: User, workspace: Workspace) -> None: validate_perm("workspace.read_workspace", user, workspace) try: team_member = TeamMember.objects.get(user=user, workspace=workspace) - except TeamMember.DoesNotExist: - # User is not a team member, nothing to do - return + except TeamMember.DoesNotExist as e: + raise RuntimeError( + f"User can read workspace {workspace.uuid} but doesn't have a team member" + ) from e team_member.last_visited_workspace = now() team_member.save() @@ -88,9 +89,22 @@ def team_member_visit_project(*, user: User, project: Project) -> None: workspace = project.workspace try: team_member = TeamMember.objects.get(user=user, workspace=workspace) - except TeamMember.DoesNotExist: - # User is not a team member, nothing to do - return + except TeamMember.DoesNotExist as e: + raise RuntimeError( + f"User can read workspace {workspace.uuid} but doesn't have a team member" + ) from e team_member.last_visited_project = project team_member.last_visited_workspace = now() team_member.save() + + +@transaction.atomic +def team_member_minimize_project_list( + *, user: User, workspace: Workspace, minimized: bool +) -> TeamMember: + """Set the minimized state of the project list for a team member.""" + validate_perm("workspace.read_workspace", user, workspace) + team_member = TeamMember.objects.get(user=user, workspace=workspace) + team_member.minimized_project_list = minimized + team_member.save() + return team_member diff --git a/backend/projectify/workspace/templates/workspace/common/sidebar/project_details.html b/backend/projectify/workspace/templates/workspace/common/sidebar/project_details.html index d02d166ce..225fbdc99 100644 --- a/backend/projectify/workspace/templates/workspace/common/sidebar/project_details.html +++ b/backend/projectify/workspace/templates/workspace/common/sidebar/project_details.html @@ -2,30 +2,59 @@ {# SPDX-FileCopyrightText: 2024-2025 JWP Consulting GK #} {% load i18n %} {% load projectify %} -{% for project_item in projects %} - -
    -
    - {% if task_filter_form %} - {% if not current_team_member.minimized_project_list %} + {% if not current_team_member_qs.minimized_project_list %} {% for project_item in projects %} diff --git a/backend/projectify/workspace/test/selectors/test_team_member.py b/backend/projectify/workspace/test/selectors/test_team_member.py index 1812eced6..a839f5f2e 100644 --- a/backend/projectify/workspace/test/selectors/test_team_member.py +++ b/backend/projectify/workspace/test/selectors/test_team_member.py @@ -35,31 +35,36 @@ def test_team_member_find_for_workspace( def test_team_member_multiple_workspaces( - user: User, workspace: Workspace, other_workspace: Workspace + team_member: TeamMember, workspace: Workspace, other_workspace: Workspace ) -> None: """Test that we receive the most recently visited workspace.""" + user = team_member.user # No last visited in the beginning assert team_member_last_workspace(user=user) is None - team_member_visit_workspace(user=user, workspace=workspace) + team_member_visit_workspace(team_member=team_member) assert team_member_last_workspace(user=user) == workspace - team_member_visit_workspace(user=user, workspace=other_workspace) + other_team_member = other_workspace.teammember_set.get() + team_member_visit_workspace(team_member=other_team_member) assert team_member_last_workspace(user=user) == other_workspace - team_member_visit_workspace(user=user, workspace=workspace) + team_member_visit_workspace(team_member=team_member) assert team_member_last_workspace(user=user) == workspace # This will not set a last visited project - assert team_member_last_project(user=user, workspace=workspace) is None + assert ( + team_member_last_project( + user=team_member.user, workspace=team_member.workspace + ) + is None + ) def test_team_member_multiple_projects( - user: User, + team_member: TeamMember, workspace: Workspace, project: Project, other_project_same_workspace: Project, - other_workspace: Workspace, - other_project: Project, ) -> None: """ Test that we get the most recently visited project in workspace A. @@ -67,30 +72,27 @@ def test_team_member_multiple_projects( Visiting project C in another workspace B should not affect the result for workspace A. """ + user = team_member.user assert team_member_last_project(user=user, workspace=workspace) is None - team_member_visit_project(user=user, project=project) + team_member_visit_project(team_member=team_member, project=project) assert team_member_last_project(user=user, workspace=workspace) == project - team_member_visit_project(user=user, project=other_project_same_workspace) + team_member_visit_project( + team_member=team_member, project=other_project_same_workspace + ) assert ( team_member_last_project(user=user, workspace=workspace) == other_project_same_workspace ) - team_member_visit_project(user=user, project=project) + team_member_visit_project(team_member=team_member, project=project) assert team_member_last_project(user=user, workspace=workspace) == project assert team_member_last_workspace(user=user) == workspace - team_member_visit_project(user=user, project=other_project) - # No change to the project for this workspace - assert team_member_last_project(user=user, workspace=workspace) == project - # But it does affect the most recently visited workspace - assert team_member_last_workspace(user=user) == other_workspace - def test_team_member_preferences_other_project( - user: User, other_workspace: Workspace, project: Project + team_member: TeamMember, other_workspace: Workspace, project: Project ) -> None: """ Test that preferences for different workspaces don't interfere. @@ -98,7 +100,8 @@ def test_team_member_preferences_other_project( When I visit project A in workspace A, then my last visited project for workspace B shouldn't change. """ - team_member_visit_project(user=user, project=project) + user = team_member.user + team_member_visit_project(team_member=team_member, project=project) assert ( team_member_last_project(user=user, workspace=other_workspace) is None diff --git a/backend/projectify/workspace/test/services/test_team_member.py b/backend/projectify/workspace/test/services/test_team_member.py index fb4761f47..f0c0bcba0 100644 --- a/backend/projectify/workspace/test/services/test_team_member.py +++ b/backend/projectify/workspace/test/services/test_team_member.py @@ -76,20 +76,20 @@ def test_team_member_delete( assert team_member.workspace.users.count() == count -def test_team_member_visit_workspace_permission_denied( - unrelated_workspace: Workspace, user: User -) -> None: - """Test that user without workspace access cannot visit workspace.""" - with pytest.raises(PermissionDenied): - team_member_visit_workspace(user=user, workspace=unrelated_workspace) +def test_team_member_visit_functions(team_member: TeamMember) -> None: + """Test visiting a workspace.""" + team_member_visit_workspace(team_member=team_member) -def test_team_member_visit_project_permission_denied( - unrelated_project: Project, user: User +def test_team_member_visit_project( + team_member: TeamMember, project: Project, other_project: Project ) -> None: - """Test that user without workspace access cannot visit project.""" - with pytest.raises(PermissionDenied): - team_member_visit_project(user=user, project=unrelated_project) + """Test visiting a project.""" + team_member_visit_project(team_member=team_member, project=project) + with pytest.raises(AssertionError): + team_member_visit_project( + team_member=team_member, project=other_project + ) def test_team_member_minimize_project_list(team_member: TeamMember) -> None: diff --git a/backend/projectify/workspace/test/views/test_project.py b/backend/projectify/workspace/test/views/test_project.py index 6d9b28ddc..90c222afb 100644 --- a/backend/projectify/workspace/test/views/test_project.py +++ b/backend/projectify/workspace/test/views/test_project.py @@ -59,7 +59,8 @@ def test_get_project_detail( # Gone down from 11 -> 10, since we don't fetch label values() # Gone up from 10 -> 13, since we update the last visited project # Gone up from 13 -> 15 - with django_assert_num_queries(15): + # Gone down from 15 -> 14 + with django_assert_num_queries(14): response = user_client.get(resource_url) assert response.status_code == 200 assert project.title in response.content.decode() @@ -109,7 +110,7 @@ def test_filter_by_team_member( other_task.assignee = other_team_member other_task.save() - with django_assert_num_queries(21): + with django_assert_num_queries(20): response = user_client.get( resource_url, {"filter_by_team_member": [str(team_member.uuid)]}, @@ -132,7 +133,7 @@ def test_filter_by_unassigned_tasks( task.assignee = team_member task.save() - with django_assert_num_queries(19): + with django_assert_num_queries(18): response = user_client.get( resource_url, {"filter_by_team_member": [""]} ) @@ -153,7 +154,7 @@ def test_filter_by_label( """Test filtering tasks by label.""" task.labels.add(label) - with django_assert_num_queries(21): + with django_assert_num_queries(20): response = user_client.get( resource_url, {"filter_by_label": [str(label.uuid)]} ) @@ -174,7 +175,7 @@ def test_filter_by_unlabeled_tasks( """Test filtering for unlabeled tasks.""" task.labels.add(label) - with django_assert_num_queries(19): + with django_assert_num_queries(18): response = user_client.get(resource_url, {"filter_by_label": [""]}) assert response.status_code == 200 @@ -196,7 +197,7 @@ def test_filter_by_task_search_query( other_task.title = "Feature request" other_task.save() - with django_assert_num_queries(19): + with django_assert_num_queries(18): response = user_client.get( resource_url, {"task_search_query": "bug"} ) diff --git a/backend/projectify/workspace/test/views/test_task.py b/backend/projectify/workspace/test/views/test_task.py index 28e1e6471..87343b1a6 100644 --- a/backend/projectify/workspace/test/views/test_task.py +++ b/backend/projectify/workspace/test/views/test_task.py @@ -59,7 +59,7 @@ def test_get_task_create( django_assert_num_queries: DjangoAssertNumQueries, ) -> None: """Test GETting the task creation page.""" - with django_assert_num_queries(11): + with django_assert_num_queries(12): response = user_client.get(resource_url) assert response.status_code == 200 assert section.title in response.content.decode() @@ -73,7 +73,7 @@ def test_create_task( ) -> None: """Test creating a task.""" initial_task_count = Task.objects.count() - with django_assert_num_queries(24): + with django_assert_num_queries(25): response = user_client.post( resource_url, { @@ -133,7 +133,7 @@ def test_get_task_update( django_assert_num_queries: DjangoAssertNumQueries, ) -> None: """Test GETting the task update page.""" - with django_assert_num_queries(15): + with django_assert_num_queries(16): response = user_client.get(resource_url) assert response.status_code == 200 assert task.title in response.content.decode() @@ -162,7 +162,7 @@ def test_update_task( ) -> None: """Test updating a task.""" original_title = task.title - with django_assert_num_queries(24): + with django_assert_num_queries(25): response = user_client.post( resource_url, { @@ -210,7 +210,7 @@ def test_add_subtask_to_existing_task( existing_subtask = task.subtask_set.get() - with django_assert_num_queries(25): + with django_assert_num_queries(26): response = user_client.post( resource_url, { @@ -267,7 +267,7 @@ def test_delete_existing_subtask( assert task.subtask_set.count() == 1 - with django_assert_num_queries(24): + with django_assert_num_queries(25): response = user_client.post( resource_url, { diff --git a/backend/projectify/workspace/test/views/test_workspace.py b/backend/projectify/workspace/test/views/test_workspace.py index b391ac098..5c8639962 100644 --- a/backend/projectify/workspace/test/views/test_workspace.py +++ b/backend/projectify/workspace/test/views/test_workspace.py @@ -66,7 +66,7 @@ def test_toggle_project_list( team_member.minimized_project_list = initial_state team_member.save() - with django_assert_num_queries(4): + with django_assert_num_queries(13): response = user_client.post( resource_url, {"minimized": post_value} ) @@ -105,7 +105,7 @@ def test_invalid_form( ) -> None: """Test form validation with invalid data.""" response = user_client.post(resource_url, {}) - assert response.status_code == 400 + assert response.status_code == 200 def test_unauthorized_workspace_access( self, @@ -157,7 +157,8 @@ def test_update_workspace_and_title( assert not workspace.picture # Query count went up from 12 -> 19 # Query count went up from 19 -> 20 - with django_assert_num_queries(20): + # Query count went up from 20 -> 22 + with django_assert_num_queries(22): response = user_client.post( resource_url, { @@ -232,7 +233,8 @@ def test_invite_new_user( # Justus 2025-07-29 query count went up 19 -> 33 XXX # Justus 2025-07-29 query count went down 33 -> 32 # Justus 2025-07-29 query count went up 32 -> 33 XXX - with django_assert_num_queries(33): + # Query count went up 33 -> 34 + with django_assert_num_queries(34): response = user_client.post( invite_url, {"email": "newuser@example.com"} ) diff --git a/backend/projectify/workspace/views/project.py b/backend/projectify/workspace/views/project.py index 30c7566bb..7017bb097 100644 --- a/backend/projectify/workspace/views/project.py +++ b/backend/projectify/workspace/views/project.py @@ -4,7 +4,7 @@ """Project views.""" import logging -from typing import Any, Optional, TypeVar +from typing import Any, Optional, TypeVar, Union from uuid import UUID from django import forms @@ -28,7 +28,7 @@ from projectify.lib.types import AuthenticatedHttpRequest from projectify.lib.views import platform_view -from ..models import Project +from ..models import Project, Workspace from ..models.label import Label from ..models.team_member import TeamMember from ..selectors.project import ( @@ -40,6 +40,7 @@ from ..selectors.quota import workspace_get_all_quotas from ..selectors.team_member import team_member_find_for_workspace from ..selectors.workspace import ( + workspace_build_detail_query_set, workspace_find_by_workspace_uuid, workspace_find_for_user, ) @@ -58,6 +59,34 @@ Q = TypeVar("Q", bound=Model) +def get_project_view_context( + request: AuthenticatedHttpRequest, workspace: Workspace +) -> dict[str, object]: + """Get shared context for project views.""" + if not hasattr(workspace, "current_team_member_qs"): + current_team_member = team_member_find_for_workspace( + user=request.user, workspace=workspace + ) + logger.warning("No current_team_member_qs in workspace") + else: + current_team_member_qs: Union[list[TeamMember], TeamMember, Any] = ( + getattr(workspace, "current_team_member_qs") + ) + match current_team_member_qs: + case TeamMember() as current_team_member: + pass + case [current_team_member]: + pass + case any: + raise RuntimeError(f"Don't know what to do with {type(any)}") + return { + "workspace": workspace, + "workspaces": workspace_find_for_user(who=request.user), + "projects": workspace.project_set.all(), + "current_team_member_qs": current_team_member, + } + + class ModelMultipleChoiceFieldWithEmpty(forms.ModelMultipleChoiceField): """Override ModelMultipleChoiceField and allow empty label.""" @@ -212,10 +241,11 @@ def project_detail_view( raise Http404(_("No project found for this uuid")) # Mark this project as most recently visited - team_member_visit_project(user=request.user, project=project) + team_member_qs = getattr(project.workspace, "current_team_member_qs", None) + assert team_member_qs + team_member_visit_project(team_member=team_member_qs[0], project=project) project.workspace.quota = workspace_get_all_quotas(project.workspace) - projects = project.workspace.project_set.all() team_members = project.workspace.teammember_set.all() labels = project.workspace.label_set.all() @@ -226,11 +256,9 @@ def project_detail_view( ) context = { + **get_project_view_context(request, project.workspace), "project": project, "labels": labels, - "projects": projects, - "workspaces": workspace_find_for_user(who=request.user), - "workspace": project.workspace, "team_members": team_members, "unassigned_tasks": filter_by_unassigned, "unlabeled_tasks": filter_by_unlabeled, @@ -255,39 +283,44 @@ class ProjectCreateForm(forms.Form): ) +@require_http_methods(["GET", "POST"]) @platform_view def project_create_view( request: AuthenticatedHttpRequest, workspace_uuid: UUID ) -> HttpResponse: """Create a new project in a workspace.""" + qs: Optional[QuerySet[Workspace]] + match request.method: + case "GET": + qs = workspace_build_detail_query_set( + who=request.user, annotate_labels=True + ) + case "POST": + qs = None + case other: + # Should never be hit + assert False, other workspace = workspace_find_by_workspace_uuid( workspace_uuid=workspace_uuid, who=request.user, + qs=qs, ) if workspace is None: raise Http404(_("No workspace found for this UUID")) - # XXX inefficient - projects = project_find_by_workspace_uuid( - who=request.user, - workspace_uuid=workspace.uuid, - archived=False, - ) - - context: dict[str, Any] = { - "workspace": workspace, - "projects": projects, - "workspaces": workspace_find_for_user(who=request.user), - } - if request.method == "GET": - form = ProjectCreateForm() - context = {"form": form, **context} + context = { + **get_project_view_context(request, workspace), + "form": ProjectCreateForm(), + } return render(request, "workspace/project_create.html", context) form = ProjectCreateForm(request.POST) if not form.is_valid(): - context = {"form": form, **context} + context = { + **get_project_view_context(request, workspace), + "form": form, + } return render( request, "workspace/project_create.html", context, status=400 ) @@ -303,7 +336,10 @@ def project_create_view( return redirect("dashboard:projects:detail", project_uuid=project.uuid) except ValidationError as error: populate_form_with_drf_errors(form, error) - context = {**context, "form": form} + context = { + **get_project_view_context(request, workspace), + "form": form, + } return render( request, "workspace/project_create.html", context, status=400 ) @@ -329,24 +365,24 @@ class ProjectUpdateForm(forms.Form): ) +@require_http_methods(["GET", "POST"]) @platform_view def project_update_view( request: AuthenticatedHttpRequest, project_uuid: UUID ) -> HttpResponse: """Update an existing project.""" + qs = project_detail_query_set(who=request.user, prefetch_labels=False) project = project_find_by_project_uuid( - who=request.user, project_uuid=project_uuid, qs=ProjectDetailQuerySet + who=request.user, project_uuid=project_uuid, qs=qs ) if project is None: raise Http404(_("No project found for this uuid")) workspace = project.workspace - context: dict[str, Any] = { + context = { + **get_project_view_context(request, workspace), "project": project, - "workspace": workspace, - "projects": project.workspace.project_set.all(), - "workspaces": workspace_find_for_user(who=request.user), } if request.method == "GET": @@ -403,18 +439,16 @@ def project_archive_view( return HttpResponseClientRefresh() +@require_http_methods(["POST"]) @platform_view def project_recover_view( request: AuthenticatedHttpRequest, project_uuid: UUID ) -> HttpResponse: """Recover an archived project via HTMX.""" - if request.method != "POST": - return HttpResponse(status=405) + assert request.method == "POST" project = project_find_by_project_uuid( - who=request.user, - project_uuid=project_uuid, - archived=True, + who=request.user, project_uuid=project_uuid, archived=True ) if project is None: raise Http404(_("No archived project found for this uuid")) @@ -443,45 +477,6 @@ def project_delete_view( return HttpResponseClientRefresh() -class MinimizeProjectListForm(forms.Form): - """Form for minimizing/expanding the project list.""" - - minimized = forms.BooleanField(required=True) - - -@require_http_methods(["POST"]) -@platform_view -def project_minimize_project_list( - request: AuthenticatedHttpRequest, workspace_uuid: UUID -) -> HttpResponse: - """Toggle the minimized state of the project list for a workspace.""" - assert request.method == "POST" - - workspace = workspace_find_by_workspace_uuid( - workspace_uuid=workspace_uuid, - who=request.user, - ) - if workspace is None: - raise Http404(_("No workspace found for this UUID")) - - team_member = team_member_find_for_workspace( - user=request.user, - workspace=workspace, - ) - if team_member is None: - raise Http404(_("You are not a member of this workspace")) - - form = MinimizeProjectListForm(request.POST) - if not form.is_valid(): - return HttpResponse(status=400) - - minimized = form.cleaned_data["minimized"] - team_member.minimized_project_list = minimized - team_member.save() - - return HttpResponse(status=200) - - # Create class ProjectCreate(APIView): """Create a project.""" diff --git a/backend/projectify/workspace/views/task.py b/backend/projectify/workspace/views/task.py index b8491b061..459264274 100644 --- a/backend/projectify/workspace/views/task.py +++ b/backend/projectify/workspace/views/task.py @@ -87,12 +87,12 @@ def get_object( def get_task_view_context( request: AuthenticatedHttpRequest, workspace: Workspace ) -> dict[str, Any]: - """Return context with workspace, workspaces and current_team_member.""" + """Return context with workspace, workspaces and current_team_member_qs.""" return { "workspace": workspace, "workspaces": workspace_find_for_user(who=request.user), "projects": workspace.project_set.all(), - "current_team_member": team_member_find_for_workspace( + "current_team_member_qs": team_member_find_for_workspace( user=request.user, workspace=workspace ), } diff --git a/backend/projectify/workspace/views/workspace.py b/backend/projectify/workspace/views/workspace.py index 5c8e5b76b..1895c964d 100644 --- a/backend/projectify/workspace/views/workspace.py +++ b/backend/projectify/workspace/views/workspace.py @@ -86,6 +86,9 @@ def _get_workspace_settings_context( "workspace": workspace, "projects": workspace.project_set.all(), "workspaces": workspace_find_for_user(who=request.user), + "current_team_member_qs": team_member_find_for_workspace( + user=request.user, workspace=workspace + ), "active_tab": active_tab, } @@ -105,7 +108,12 @@ def workspace_view( raise Http404(_("Workspace not found")) # Mark this workspace as most recently visited - team_member_visit_workspace(user=request.user, workspace=workspace) + team_member = team_member_find_for_workspace( + user=request.user, workspace=workspace + ) + if team_member is None: + raise Http404(_("Team member not found")) + team_member_visit_workspace(team_member=team_member) # Check whether the user has already visited a project within this # workspace @@ -145,7 +153,9 @@ def workspace_minimize_project_list( """Toggle the minimized state of the project list.""" assert request.method == "POST" workspace = workspace_find_by_workspace_uuid( - workspace_uuid=workspace_uuid, who=request.user + workspace_uuid=workspace_uuid, + who=request.user, + qs=WorkspaceDetailQuerySet, ) if workspace is None: raise Http404(_("No workspace found for this UUID")) @@ -160,7 +170,7 @@ def workspace_minimize_project_list( minimized=form.cleaned_data["minimized"], ) - current_team_member = team_member_find_for_workspace( + current_team_member_qs = team_member_find_for_workspace( user=request.user, workspace=workspace ) @@ -168,7 +178,7 @@ def workspace_minimize_project_list( "workspace": workspace, "project": form.cleaned_data.get("current_project"), "projects": workspace.project_set.all(), - "current_team_member": current_team_member, + "current_team_member_qs": current_team_member_qs, } return render( From c589d6ee9dc8630b1c59560731e78e6f92683dd9 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Sat, 14 Feb 2026 19:30:36 +0900 Subject: [PATCH 14/43] BE: Rename selector --- .../workspace/selectors/team_member.py | 4 +-- .../test/selectors/test_team_member.py | 36 ++++++++++++------- .../projectify/workspace/views/dashboard.py | 8 ++--- .../projectify/workspace/views/workspace.py | 4 +-- 4 files changed, 31 insertions(+), 21 deletions(-) diff --git a/backend/projectify/workspace/selectors/team_member.py b/backend/projectify/workspace/selectors/team_member.py index c7fcc490b..810c95bd8 100644 --- a/backend/projectify/workspace/selectors/team_member.py +++ b/backend/projectify/workspace/selectors/team_member.py @@ -34,7 +34,7 @@ def team_member_find_by_team_member_uuid( return None -def team_member_last_workspace(*, user: User) -> Optional[Workspace]: +def team_member_last_workspace_for_user(*, user: User) -> Optional[Workspace]: """Return the last workspace that the user visited (or None).""" match ( TeamMember.objects.filter( @@ -50,7 +50,7 @@ def team_member_last_workspace(*, user: User) -> Optional[Workspace]: return None -def team_member_last_project( +def team_member_last_project_for_user( *, user: User, workspace: Workspace ) -> Optional[Project]: """Get the last project that the user visited in a given workspace.""" diff --git a/backend/projectify/workspace/test/selectors/test_team_member.py b/backend/projectify/workspace/test/selectors/test_team_member.py index a839f5f2e..d120b83fc 100644 --- a/backend/projectify/workspace/test/selectors/test_team_member.py +++ b/backend/projectify/workspace/test/selectors/test_team_member.py @@ -11,8 +11,8 @@ from projectify.workspace.models.workspace import Workspace from projectify.workspace.selectors.team_member import ( team_member_find_for_workspace, - team_member_last_project, - team_member_last_workspace, + team_member_last_project_for_user, + team_member_last_workspace_for_user, ) from projectify.workspace.services.team_member import ( team_member_visit_project, @@ -40,20 +40,20 @@ def test_team_member_multiple_workspaces( """Test that we receive the most recently visited workspace.""" user = team_member.user # No last visited in the beginning - assert team_member_last_workspace(user=user) is None + assert team_member_last_workspace_for_user(user=user) is None team_member_visit_workspace(team_member=team_member) - assert team_member_last_workspace(user=user) == workspace + assert team_member_last_workspace_for_user(user=user) == workspace other_team_member = other_workspace.teammember_set.get() team_member_visit_workspace(team_member=other_team_member) - assert team_member_last_workspace(user=user) == other_workspace + assert team_member_last_workspace_for_user(user=user) == other_workspace team_member_visit_workspace(team_member=team_member) - assert team_member_last_workspace(user=user) == workspace + assert team_member_last_workspace_for_user(user=user) == workspace # This will not set a last visited project assert ( - team_member_last_project( + team_member_last_project_for_user( user=team_member.user, workspace=team_member.workspace ) is None @@ -73,22 +73,31 @@ def test_team_member_multiple_projects( result for workspace A. """ user = team_member.user - assert team_member_last_project(user=user, workspace=workspace) is None + assert ( + team_member_last_project_for_user(user=user, workspace=workspace) + is None + ) team_member_visit_project(team_member=team_member, project=project) - assert team_member_last_project(user=user, workspace=workspace) == project + assert ( + team_member_last_project_for_user(user=user, workspace=workspace) + == project + ) team_member_visit_project( team_member=team_member, project=other_project_same_workspace ) assert ( - team_member_last_project(user=user, workspace=workspace) + team_member_last_project_for_user(user=user, workspace=workspace) == other_project_same_workspace ) team_member_visit_project(team_member=team_member, project=project) - assert team_member_last_project(user=user, workspace=workspace) == project - assert team_member_last_workspace(user=user) == workspace + assert ( + team_member_last_project_for_user(user=user, workspace=workspace) + == project + ) + assert team_member_last_workspace_for_user(user=user) == workspace def test_team_member_preferences_other_project( @@ -104,5 +113,6 @@ def test_team_member_preferences_other_project( team_member_visit_project(team_member=team_member, project=project) assert ( - team_member_last_project(user=user, workspace=other_workspace) is None + team_member_last_project_for_user(user=user, workspace=other_workspace) + is None ) diff --git a/backend/projectify/workspace/views/dashboard.py b/backend/projectify/workspace/views/dashboard.py index d62963b62..7626f1768 100644 --- a/backend/projectify/workspace/views/dashboard.py +++ b/backend/projectify/workspace/views/dashboard.py @@ -12,8 +12,8 @@ project_find_by_workspace_uuid, ) from projectify.workspace.selectors.team_member import ( - team_member_last_project, - team_member_last_workspace, + team_member_last_project_for_user, + team_member_last_workspace_for_user, ) from projectify.workspace.selectors.workspace import workspace_find_for_user @@ -24,11 +24,11 @@ def redirect_to_dashboard(request: AuthenticatedHttpRequest) -> HttpResponse: # that returns either a workspace or project # but still, if we're going to a workspace, we should remember what # project we looked at last - maybe_last_visited_workspace = team_member_last_workspace( + maybe_last_visited_workspace = team_member_last_workspace_for_user( user=request.user ) if maybe_last_visited_workspace: - maybe_project = team_member_last_project( + maybe_project = team_member_last_project_for_user( user=request.user, workspace=maybe_last_visited_workspace ) if maybe_project: diff --git a/backend/projectify/workspace/views/workspace.py b/backend/projectify/workspace/views/workspace.py index 1895c964d..3e63982ea 100644 --- a/backend/projectify/workspace/views/workspace.py +++ b/backend/projectify/workspace/views/workspace.py @@ -50,7 +50,7 @@ from ..selectors.team_member import ( team_member_find_by_team_member_uuid, team_member_find_for_workspace, - team_member_last_project, + team_member_last_project_for_user, ) from ..selectors.workspace import ( WorkspaceDetailQuerySet, @@ -117,7 +117,7 @@ def workspace_view( # Check whether the user has already visited a project within this # workspace - last_project = team_member_last_project( + last_project = team_member_last_project_for_user( user=request.user, workspace=workspace ) if last_project: From 86168a3cdce08e95a255b5accb5158440d525775 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Sat, 14 Feb 2026 23:31:58 +0900 Subject: [PATCH 15/43] BE: Add label/member list minimize --- backend/projectify/templatetags/projectify.py | 2 +- .../0071_teammember_minimized_filters.py | 33 +++++ .../workspace/models/team_member.py | 12 ++ .../workspace/services/team_member.py | 25 +++- .../common/sidebar/project_details.html | 94 ++++++++++---- .../forms/widgets/select_label_option.html | 2 +- .../templates/workspace/project_detail.html | 7 +- .../templates/workspace/task_detail.html | 4 +- .../test/services/test_team_member.py | 56 ++++++--- .../workspace/test/views/test_project.py | 93 +++++++++++++- .../workspace/test/views/test_workspace.py | 6 +- backend/projectify/workspace/views/project.py | 115 +++++++++++++----- .../projectify/workspace/views/workspace.py | 13 +- 13 files changed, 367 insertions(+), 95 deletions(-) create mode 100644 backend/projectify/workspace/migrations/0071_teammember_minimized_filters.py diff --git a/backend/projectify/templatetags/projectify.py b/backend/projectify/templatetags/projectify.py index bb6b438fd..59c152f0b 100644 --- a/backend/projectify/templatetags/projectify.py +++ b/backend/projectify/templatetags/projectify.py @@ -129,7 +129,7 @@ def icon( src = static.static(static_path) return format_html( - '{icon} icon', + '', src=src, icon=icon, ) diff --git a/backend/projectify/workspace/migrations/0071_teammember_minimized_filters.py b/backend/projectify/workspace/migrations/0071_teammember_minimized_filters.py new file mode 100644 index 000000000..912435610 --- /dev/null +++ b/backend/projectify/workspace/migrations/0071_teammember_minimized_filters.py @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# SPDX-FileCopyrightText: 2026 JWP Consulting GK +"""Add minimized_team_member_filter and minimized_label_filter fields to TeamMember model.""" + +from django.db import migrations, models + + +class Migration(migrations.Migration): + """Run the migration.""" + + dependencies = [ + ("workspace", "0070_teammember_minimized_project_list"), + ] + + operations = [ + migrations.AddField( + model_name="teammember", + name="minimized_team_member_filter", + field=models.BooleanField( + default=False, + help_text="Whether this team member has minimized the team member filter in this workspace", + ), + ), + migrations.AddField( + model_name="teammember", + name="minimized_label_filter", + field=models.BooleanField( + default=False, + help_text="Whether this team member has minimized the label filter in this workspace", + ), + ), + ] diff --git a/backend/projectify/workspace/models/team_member.py b/backend/projectify/workspace/models/team_member.py index 9f91d0935..697c68620 100644 --- a/backend/projectify/workspace/models/team_member.py +++ b/backend/projectify/workspace/models/team_member.py @@ -70,6 +70,18 @@ class TeamMember(BaseModel): "Whether this team member has minimized the project list in this workspace" ), ) + minimized_team_member_filter = models.BooleanField( + default=False, + help_text=_( + "Whether this team member has minimized the team member filter in this workspace" + ), + ) + minimized_label_filter = models.BooleanField( + default=False, + help_text=_( + "Whether this team member has minimized the label filter in this workspace" + ), + ) if TYPE_CHECKING: # Related diff --git a/backend/projectify/workspace/services/team_member.py b/backend/projectify/workspace/services/team_member.py index 76143dd81..52287ad35 100644 --- a/backend/projectify/workspace/services/team_member.py +++ b/backend/projectify/workspace/services/team_member.py @@ -13,7 +13,6 @@ from projectify.workspace.models.const import TeamMemberRoles from projectify.workspace.models.project import Project from projectify.workspace.models.team_member import TeamMember -from projectify.workspace.models.workspace import Workspace from projectify.workspace.services.signals import send_change_signal @@ -88,11 +87,29 @@ def team_member_visit_project( @transaction.atomic def team_member_minimize_project_list( - *, user: User, workspace: Workspace, minimized: bool + *, team_member: TeamMember, minimized: bool ) -> TeamMember: """Set the minimized state of the project list for a team member.""" - validate_perm("workspace.read_workspace", user, workspace) - team_member = TeamMember.objects.get(user=user, workspace=workspace) team_member.minimized_project_list = minimized team_member.save() return team_member + + +@transaction.atomic +def team_member_minimize_team_member_filter( + *, team_member: TeamMember, minimized: bool +) -> TeamMember: + """Set the minimized state of the team member filter for a team member.""" + team_member.minimized_team_member_filter = minimized + team_member.save() + return team_member + + +@transaction.atomic +def team_member_minimize_label_filter( + *, team_member: TeamMember, minimized: bool +) -> TeamMember: + """Set the minimized state of the label filter for a team member.""" + team_member.minimized_label_filter = minimized + team_member.save() + return team_member diff --git a/backend/projectify/workspace/templates/workspace/common/sidebar/project_details.html b/backend/projectify/workspace/templates/workspace/common/sidebar/project_details.html index ed8875fb7..d9affbf8d 100644 --- a/backend/projectify/workspace/templates/workspace/common/sidebar/project_details.html +++ b/backend/projectify/workspace/templates/workspace/common/sidebar/project_details.html @@ -7,10 +7,10 @@ hx-swap="outerHTML" hx-target="#sidebar-project-section"> {% csrf_token %} - {% if project %}{% endif %} + {% if project %}{% endif %} +
    +
    +
    {{ task_filter_form.filter_by_team_member }}
    +
    +
    +
    +
    + + +
    + {{ task_filter_form.filter_by_label }} {% if workspace %} {% anchor "dashboard:workspaces:labels" _("Edit labels") workspace_uuid=workspace.uuid %} {% endif %} - {% include "projectify/forms/submit.html" with text=_("Filter") %} -
    - + + {% include "projectify/forms/submit.html" with text=_("Filter") form_name="task-filter" %} +
    {% endif %} diff --git a/backend/projectify/workspace/templates/workspace/forms/widgets/select_label_option.html b/backend/projectify/workspace/templates/workspace/forms/widgets/select_label_option.html index 773dba7e1..dff633cab 100644 --- a/backend/projectify/workspace/templates/workspace/forms/widgets/select_label_option.html +++ b/backend/projectify/workspace/templates/workspace/forms/widgets/select_label_option.html @@ -6,7 +6,7 @@ value="{{ widget.value }}" class="cursor-pointer" {% include "django/forms/widgets/attrs.html" %}> - diff --git a/backend/projectify/workspace/templates/workspace/project_detail.html b/backend/projectify/workspace/templates/workspace/project_detail.html index 4e9226491..dbde18ef3 100644 --- a/backend/projectify/workspace/templates/workspace/project_detail.html +++ b/backend/projectify/workspace/templates/workspace/project_detail.html @@ -9,7 +9,8 @@ + {% if workspace %} + + {% icon "plus" "primary" %} + {% translate "Add more team members" %} + + {% endif %}
    + {% empty %} + + +

    + {% translate "No tasks in this section." %} + {% url 'dashboard:sections:create-task' section.uuid as create_task_url %} + {% anchor create_task_url _("Add a task here") %} +

    + + {% endfor %} From 8950da4864cba193c286858cc04366fb557dd8ee Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Sun, 15 Feb 2026 00:06:28 +0900 Subject: [PATCH 18/43] BE: Show that section is empty after filtering --- backend/projectify/workspace/selectors/project.py | 4 +++- .../templates/workspace/project_detail/section.html | 12 ++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/backend/projectify/workspace/selectors/project.py b/backend/projectify/workspace/selectors/project.py index f7d2dabdf..2529afc70 100644 --- a/backend/projectify/workspace/selectors/project.py +++ b/backend/projectify/workspace/selectors/project.py @@ -128,7 +128,9 @@ def project_detail_query_set( Prefetch("labels", queryset=task_label_prefetch_qs) ) - section_qs = Section.objects.all() + section_qs = Section.objects.all().annotate( + has_tasks=Exists(Task.objects.filter(section_id=OuterRef("pk"))), + ) # If caller provides a user, filter out tasks for hidden sections, # and mark these sections as minimized if who is not None: diff --git a/backend/projectify/workspace/templates/workspace/project_detail/section.html b/backend/projectify/workspace/templates/workspace/project_detail/section.html index f2bd856b7..30318c0dc 100644 --- a/backend/projectify/workspace/templates/workspace/project_detail/section.html +++ b/backend/projectify/workspace/templates/workspace/project_detail/section.html @@ -114,16 +114,16 @@

    {{ section.title }}

    {% empty %} - -

    + + {% if section.has_tasks %} + {% translate "No tasks found for your search." %} + {% else %} {% translate "No tasks in this section." %} - {% url 'dashboard:sections:create-task' section.uuid as create_task_url %} - {% anchor create_task_url _("Add a task here") %} -

    + {% anchor 'dashboard:sections:create-task' label=_("Add a task here") section_uuid=section.uuid %} + {% endif %} {% endfor %} - {% endif %} From a512ffc631403e71109966aa9377c9fa60d6eb5c Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Sun, 15 Feb 2026 00:35:10 +0900 Subject: [PATCH 19/43] BE: Improve section minimize --- .../projectify/workspace/dashboard_urls.py | 4 - .../workspace/project_detail/section.html | 11 +- .../workspace/test/views/test_project.py | 70 +++++++++++- .../workspace/test/views/test_section.py | 55 --------- backend/projectify/workspace/views/project.py | 106 +++++++++++------- backend/projectify/workspace/views/section.py | 34 ------ 6 files changed, 139 insertions(+), 141 deletions(-) diff --git a/backend/projectify/workspace/dashboard_urls.py b/backend/projectify/workspace/dashboard_urls.py index 8a05a31e8..c6f65ff50 100644 --- a/backend/projectify/workspace/dashboard_urls.py +++ b/backend/projectify/workspace/dashboard_urls.py @@ -19,7 +19,6 @@ section_create_view, section_delete_view, section_detail, - section_minimize_view, section_update_view, ) from projectify.workspace.views.task import ( @@ -167,9 +166,6 @@ path("", section_detail, name="detail"), path("/update", section_update_view, name="update"), path("/delete", section_delete_view, name="delete"), - path( - "/minimize", section_minimize_view, name="minimize" - ), # Create task within section path("/create-task", task_create, name="create-task"), ) diff --git a/backend/projectify/workspace/templates/workspace/project_detail/section.html b/backend/projectify/workspace/templates/workspace/project_detail/section.html index 30318c0dc..0ebb51fec 100644 --- a/backend/projectify/workspace/templates/workspace/project_detail/section.html +++ b/backend/projectify/workspace/templates/workspace/project_detail/section.html @@ -2,15 +2,18 @@ {# SPDX-License-Identifier: AGPL-3.0-or-later #} {% load i18n %} {% load projectify %} -
    -
    +
    +
    + hx-select="#section-{{ section.uuid }}" + hx-include="#task-filter,#team-member-filters,#label-filters"> {% csrf_token %} + + diff --git a/backend/projectify/workspace/test/views/test_project.py b/backend/projectify/workspace/test/views/test_project.py index f288bcda9..5627c271d 100644 --- a/backend/projectify/workspace/test/views/test_project.py +++ b/backend/projectify/workspace/test/views/test_project.py @@ -60,7 +60,8 @@ def test_get_project_detail( # Gone up from 10 -> 13, since we update the last visited project # Gone up from 13 -> 15 # Gone down from 15 -> 14 - with django_assert_num_queries(14): + # Gone up from 14 -> 15 + with django_assert_num_queries(15): response = user_client.get(resource_url) assert response.status_code == 200 assert project.title in response.content.decode() @@ -110,7 +111,7 @@ def test_filter_by_team_member( other_task.assignee = other_team_member other_task.save() - with django_assert_num_queries(21): + with django_assert_num_queries(20): response = user_client.get( resource_url, {"filter_by_team_member": [str(team_member.uuid)]}, @@ -672,6 +673,71 @@ def test_minimize_preserves_get_parameters( # The view re-renders with the same GET parameters assert b"task_search_query" in response.content + @pytest.mark.parametrize( + "initial_state,form_value,expected_final_state", + [ + (False, "true", True), # minimize section + (True, "false", False), # expand section + ], + ) + def test_section_minimize_toggle( + self, + user_client: Client, + team_member: TeamMember, + section: Section, + initial_state: bool, + form_value: str, + expected_final_state: bool, + django_assert_num_queries: DjangoAssertNumQueries, + ) -> None: + """Test minimizing and expanding a section.""" + if initial_state: + section.minimized_by.add(team_member.user) + + assert ( + section.minimized_by.filter(pk=team_member.user.pk).exists() + == initial_state + ) + + url = reverse( + "dashboard:projects:detail", args=(section.project.uuid,) + ) + + with django_assert_num_queries(20): + response = user_client.post( + url, + { + "action": "minimize_section", + "section": str(section.uuid), + "minimized": form_value, + }, + ) + assert response.status_code == 200 + + section.refresh_from_db() + assert ( + section.minimized_by.filter(pk=team_member.user.pk).exists() + == expected_final_state + ) + + def test_minimize_section_not_found( + self, + user_client: Client, + team_member: TeamMember, + project: Project, + ) -> None: + """Test minimize view with non-existent section.""" + url = reverse("dashboard:projects:detail", args=(project.uuid,)) + response = user_client.post( + url, + { + "action": "minimize_section", + "section": str(uuid4()), + "minimized": "true", + }, + ) + assert response.status_code == 400 + # Create class TestProjectCreate: diff --git a/backend/projectify/workspace/test/views/test_section.py b/backend/projectify/workspace/test/views/test_section.py index 2bbbaaaec..a1ae46f15 100644 --- a/backend/projectify/workspace/test/views/test_section.py +++ b/backend/projectify/workspace/test/views/test_section.py @@ -178,61 +178,6 @@ def test_section_not_found( assert response.status_code == 404 -class TestSectionMinimizeView: - """Test section minimize view.""" - - @pytest.mark.parametrize( - "initial_state,form_value,expected_final_state", - [ - (False, "true", True), # minimize section - (True, "false", False), # expand section - ], - ) - def test_section_minimize_toggle( - self, - user_client: Client, - team_member: TeamMember, - section: Section, - initial_state: bool, - form_value: str, - expected_final_state: bool, - django_assert_num_queries: DjangoAssertNumQueries, - ) -> None: - """Test minimizing and expanding a section.""" - if initial_state: - section.minimized_by.add(team_member.user) - - assert ( - section.minimized_by.filter(pk=team_member.user.pk).exists() - == initial_state - ) - - with django_assert_num_queries(8): - response = user_client.post( - reverse("dashboard:sections:minimize", args=[section.uuid]), - {"minimized": form_value}, - ) - assert response.status_code == 200 - - section.refresh_from_db() - assert ( - section.minimized_by.filter(pk=team_member.user.pk).exists() - == expected_final_state - ) - - def test_minimize_section_different_workspace( - self, user_client: Client, unrelated_section: Section - ) -> None: - """Test minimize view with section from different workspace.""" - response = user_client.post( - reverse( - "dashboard:sections:minimize", args=[unrelated_section.uuid] - ), - {"minimized": "true"}, - ) - assert response.status_code == 404 - - # Create class TestSectionCreate: """Test section creation.""" diff --git a/backend/projectify/workspace/views/project.py b/backend/projectify/workspace/views/project.py index 73c95a5fc..13ac41990 100644 --- a/backend/projectify/workspace/views/project.py +++ b/backend/projectify/workspace/views/project.py @@ -52,6 +52,7 @@ project_delete, project_update, ) +from ..services.section import section_minimize from ..services.team_member import ( team_member_minimize_label_filter, team_member_minimize_team_member_filter, @@ -199,6 +200,22 @@ class MinimizeForm(forms.Form): minimized = forms.BooleanField(required=False) +class SectionMinimizeForm(forms.Form): + """Form for handling section minimize actions.""" + + action = forms.CharField(required=True) + minimized = forms.BooleanField(required=False) + + def __init__(self, project: Project, *args: Any, **kwargs: Any) -> None: + """Initialize form with section choices from project.""" + super().__init__(*args, **kwargs) + self.fields["section"] = forms.ModelChoiceField( + queryset=project.section_set.all(), + to_field_name="uuid", + required=True, + ) + + # HTML @require_http_methods(["GET", "POST"]) @platform_view @@ -206,43 +223,58 @@ def project_detail_view( request: AuthenticatedHttpRequest, project_uuid: UUID ) -> HttpResponse: """Show project details.""" - if request.method == "POST": - minimize_form = MinimizeForm(request.POST) - if not minimize_form.is_valid(): - raise BadRequest() + project = project_find_by_project_uuid( + who=request.user, project_uuid=project_uuid + ) + if project is None: + raise Http404(_("No project found for this uuid")) + match request.method, request.POST.get("action"): + case "POST", "minimize_section": + section_minimize_form = SectionMinimizeForm( + project=project, data=request.POST + ) + if not section_minimize_form.is_valid(): + raise BadRequest() - action = minimize_form.cleaned_data["action"] - minimized = minimize_form.cleaned_data["minimized"] + section = section_minimize_form.cleaned_data["section"] + minimized = section_minimize_form.cleaned_data["minimized"] - project = project_find_by_project_uuid( - who=request.user, project_uuid=project_uuid - ) - if project is None: - raise Http404(_("No project found for this uuid")) + section_minimize( + who=request.user, section=section, minimized=minimized + ) - team_member = team_member_find_for_workspace( - user=request.user, workspace=project.workspace - ) - if team_member is None: - raise RuntimeError("No team member") + querydict = request.POST + case "POST", _: + minimize_form = MinimizeForm(request.POST) + if not minimize_form.is_valid(): + raise BadRequest() - match action: - case "minimize_team_member_filter": - team_member_minimize_team_member_filter( - team_member=team_member, - minimized=minimized, - ) - case "minimize_label_filter": - team_member_minimize_label_filter( - team_member=team_member, - minimized=minimized, - ) - case invalid: - logger.warning("Invalid action %s", invalid) + action = minimize_form.cleaned_data["action"] + minimized = minimize_form.cleaned_data["minimized"] - querydict = request.POST - else: - querydict = request.GET + team_member = team_member_find_for_workspace( + user=request.user, workspace=project.workspace + ) + if team_member is None: + raise RuntimeError("No team member") + + match action: + case "minimize_team_member_filter": + team_member_minimize_team_member_filter( + team_member=team_member, + minimized=minimized, + ) + case "minimize_label_filter": + team_member_minimize_label_filter( + team_member=team_member, + minimized=minimized, + ) + case invalid: + logger.warning("Invalid action %s", invalid) + + querydict = request.POST + case _: + querydict = request.GET filter_by_team_member: Optional[QuerySet[TeamMember]] = None filter_by_label: Optional[QuerySet[Label]] = None @@ -250,15 +282,6 @@ def project_detail_view( filter_by_unassigned: bool = False task_search_query: Optional[str] = None if len(querydict): - logger.info("querydict %s", querydict) - # We need to query the project an additional round here to - # establish whether the labels and team members given to us - # by the user are valid, or not - project = project_find_by_project_uuid( - who=request.user, project_uuid=project_uuid - ) - if project is None: - raise Http404(_("No project found for this uuid")) labels = project.workspace.label_set.all() team_members = project.workspace.teammember_set.all() @@ -267,7 +290,6 @@ def project_detail_view( ) if not task_filter_form.is_valid(): raise BadRequest(task_filter_form.errors) - logger.info("filter form %s", task_filter_form.cleaned_data) filter_by_unassigned, filter_by_team_member = ( task_filter_form.cleaned_data["filter_by_team_member"] diff --git a/backend/projectify/workspace/views/section.py b/backend/projectify/workspace/views/section.py index 8354b7b2e..dec41b2da 100644 --- a/backend/projectify/workspace/views/section.py +++ b/backend/projectify/workspace/views/section.py @@ -37,7 +37,6 @@ from projectify.workspace.services.section import ( section_create, section_delete, - section_minimize, section_move, section_move_in_direction, section_update, @@ -201,39 +200,6 @@ def section_delete_view( return HttpResponseClientRedirect(project_url) -class SectionMinimizeForm(forms.Form): - """Form for minimizing and expanding a section.""" - - minimized = forms.BooleanField(required=False) - - -@platform_view -@require_POST -def section_minimize_view( - request: AuthenticatedHttpRequest, section_uuid: UUID -) -> HttpResponse: - """Toggle section minimize state.""" - section = section_find_for_user_and_uuid( - user=request.user, section_uuid=section_uuid, qs=SectionDetailQuerySet - ) - if section is None: - raise Http404(_("Section not found for this UUID")) - - form = SectionMinimizeForm(request.POST) - if not form.is_valid(): - return HttpResponse("Invalid form data", status=400) - - minimized = form.cleaned_data["minimized"] - section_minimize(who=request.user, section=section, minimized=minimized) - setattr(section, "minimized", minimized) - context = { - "section": section, - } - return render( - request, "workspace/project_detail/section.html", context=context - ) - - class SectionCreate(APIView): """Create a section.""" From 1705e21ac76c7e54b1e25d6597da75dd4b3b8b7a Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Sun, 15 Feb 2026 00:36:19 +0900 Subject: [PATCH 20/43] BE: Improve task detail empty states --- .../templates/workspace/task_detail.html | 108 +++++++++--------- 1 file changed, 52 insertions(+), 56 deletions(-) diff --git a/backend/projectify/workspace/templates/workspace/task_detail.html b/backend/projectify/workspace/templates/workspace/task_detail.html index 8908e577d..9dc094103 100644 --- a/backend/projectify/workspace/templates/workspace/task_detail.html +++ b/backend/projectify/workspace/templates/workspace/task_detail.html @@ -36,85 +36,81 @@ {% trans "Task title" %} - -
    - {{ task.title }} - -
    {% icon "pencil" %}
    -
    -
    + + {{ task.title }} + +
    {% icon "pencil" %}
    +
    {% trans "Assignee" %} - -
    - - -
    {% icon "pencil" %}
    -
    -
    + + + +
    {% icon "pencil" %}
    +
    {% trans "Labels" %} - -
    -
    - {% for label in task.labels.all %} -
    - -
    - {% endfor %} -
    - -
    {% icon "pencil" %}
    -
    + +
    + {% for label in task.labels.all %} +
    + +
    + {% endfor %}
    + +
    {% icon "pencil" %}
    +
    {% trans "Due date" %} - -
    + + {% if task.due_date %} {{ task.due_date.date.isoformat }} - -
    {% icon "pencil" %}
    -
    -
    + {% else %} +

    {% trans "No due date" %}

    + {% endif %} + +
    {% icon "pencil" %}
    +
    {% trans "Description" %} - -
    + + {% if task.description %}

    {{ task.description }}

    - -
    {% icon "pencil" %}
    -
    -
    + {% else %} +

    {% trans "No description" %}

    + {% endif %} + +
    {% icon "pencil" %}
    +
    -
    -
    -

    {% trans "Sub tasks" %}

    - -
    {% icon "pencil" %}
    -
    -
    +
    +

    {% trans "Sub tasks" %}

    + +
    {% icon "pencil" %}
    +
    {% if task.sub_task_progress %}
    @@ -141,7 +137,7 @@

    {% trans "Sub tasks" %}

    -
    +
    Date: Sun, 15 Feb 2026 00:37:29 +0900 Subject: [PATCH 21/43] BE: Fix missing task count --- .../templates/workspace/forms/widgets/select_label_option.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/projectify/workspace/templates/workspace/forms/widgets/select_label_option.html b/backend/projectify/workspace/templates/workspace/forms/widgets/select_label_option.html index dff633cab..e96686775 100644 --- a/backend/projectify/workspace/templates/workspace/forms/widgets/select_label_option.html +++ b/backend/projectify/workspace/templates/workspace/forms/widgets/select_label_option.html @@ -10,7 +10,7 @@ for="{{ widget.attrs.id }}"> {{ widget.label }} - {% if widget.value.instance %} + {% if widget.value.instance and widget.value.instance.task_count %}
    {{ widget.value.instance.task_count }}
    From 7e12d915db1be3fe58e07452081c6dc9f0e038bb Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Sun, 15 Feb 2026 01:14:18 +0900 Subject: [PATCH 22/43] BE: Refactor task details --- .../templates/workspace/task_detail.html | 288 +++++++++--------- .../templates/workspace/task_update.html | 2 +- backend/projectify/workspace/views/task.py | 155 ++++++---- 3 files changed, 236 insertions(+), 209 deletions(-) diff --git a/backend/projectify/workspace/templates/workspace/task_detail.html b/backend/projectify/workspace/templates/workspace/task_detail.html index 9dc094103..947234135 100644 --- a/backend/projectify/workspace/templates/workspace/task_detail.html +++ b/backend/projectify/workspace/templates/workspace/task_detail.html @@ -5,162 +5,156 @@ {% load rules %} {% load i18n %} {% block dashboard_content %} -
    - {% trans "We never sell your data. Ever." as title %} - {% trans "Our platform fully complies with GDPR regulations, amongst others, so you can be rest assured that your private information stays private." as text %} - {% trans "An illustration showing our mascot Poly safekeeping your data" as alt %} - {% include "storefront/common/solutions_detail_item.html" with title=title text=text src="privacy.png" alt=alt %} + {% include "storefront/common/solutions_detail_item.html" with title=_("We never sell your data. Ever.") text=_("Our platform fully complies with GDPR regulations, amongst others, so you can rest assured that your private information stays private.") src="privacy.png" alt=_("An illustration showing our mascot Poly safekeeping your data") %} {% trans "Projectify is 100% Free Software" as title %} - {% trans "We respect your freedom and privacy and provide you the source code under a Free Software license. The Projectify application is licensed under the GNU Affero General Public License (AGPL) version 3.0 or later." as text %} - {% trans "Birds freed from their gilded cage" as alt %} - {% include "storefront/common/solutions_detail_item.html" with title=title text=text src="open-source.png" alt=alt imgPosition="left" learnmore_url="/free-software" %} + {% include "storefront/common/solutions_detail_item.html" with title=title text=_("We respect your freedom and privacy and provide you with the source code under a Free Software license. The Projectify application is licensed under the GNU Affero General Public License (AGPL) version 3.0 or later.") src="open-source.png" alt=_("Birds freed from their gilded cages") imgPosition="left" learnmore_url="storefront:free_software" %}
    From f9833cc6bdc7b7a29c9e7fab70485ff6c44a75c8 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Sun, 15 Feb 2026 09:16:14 +0900 Subject: [PATCH 24/43] BE: Improve landing HTML Picture tags weren't used --- .../templates/storefront/index.html | 40 ++++++++----------- 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/backend/projectify/storefront/templates/storefront/index.html b/backend/projectify/storefront/templates/storefront/index.html index 821ddeb77..a3e99c25f 100644 --- a/backend/projectify/storefront/templates/storefront/index.html +++ b/backend/projectify/storefront/templates/storefront/index.html @@ -29,12 +29,10 @@

    {% trans "Manage projects the right w {% endif %}
    - - {% trans - + {% trans
    @@ -63,31 +61,25 @@

    {% trans "Meeting diverse needs with
    - - {% trans - + {% trans {% anchor "storefront:solutions:detail" "Research" page="research" %}
    - - {% trans - + {% trans {% anchor "storefront:solutions:detail" "Project management" page="project-management" %}
    From 47db6911e8a3dd89764f071a072b0372657591a4 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Sun, 15 Feb 2026 09:19:30 +0900 Subject: [PATCH 25/43] BE: Fix spelling issues --- .../storefront/templates/storefront/solutions/academic.html | 2 +- .../templates/storefront/solutions/development-teams.html | 2 +- .../storefront/templates/storefront/solutions/personal-use.html | 2 +- .../templates/storefront/solutions/project-management.html | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/projectify/storefront/templates/storefront/solutions/academic.html b/backend/projectify/storefront/templates/storefront/solutions/academic.html index 7993e4a65..a29f61cb5 100644 --- a/backend/projectify/storefront/templates/storefront/solutions/academic.html +++ b/backend/projectify/storefront/templates/storefront/solutions/academic.html @@ -14,7 +14,7 @@ {% include "storefront/common/solutions_header.html" with title=title text=text src="solutions/hero-academic.png" alt=alt %}
    - {% trans "Checklists that aid your progres" as title %} + {% trans "Checklists that aid your progress" as title %} {% trans "We know that projects and dissertations can be daunting, but we're here to help. Breaking tasks into smaller sub tasks allow you to micro-achieve your way to completion." as text %} {% trans "An illustration showing how users can track subtask progress for each task" as alt %} {% include "storefront/common/solutions_detail_item.html" with title=title text=text src="solutions/academic-sub-task.png" alt=alt imgPosition="left" %} diff --git a/backend/projectify/storefront/templates/storefront/solutions/development-teams.html b/backend/projectify/storefront/templates/storefront/solutions/development-teams.html index 6e10d1106..62c3f73f5 100644 --- a/backend/projectify/storefront/templates/storefront/solutions/development-teams.html +++ b/backend/projectify/storefront/templates/storefront/solutions/development-teams.html @@ -20,7 +20,7 @@ {% include "storefront/common/solutions_detail_item.html" with title=title text=text src="solutions/development-teams-filter.png" alt=alt %} {% trans "Plan and execute" as title %} {% trans "Set up and monitor pull requests, merges, bug fixes and more for multiple team members of your team." as text %} - {% trans "An illlustration showing tasks in a section called 'In Progress'" as alt %} + {% trans "An illustration showing tasks in a section called 'In Progress'" as alt %} {% include "storefront/common/solutions_detail_item.html" with title=title text=text src="solutions/development-teams-tasks.png" alt=alt %}
    diff --git a/backend/projectify/storefront/templates/storefront/solutions/personal-use.html b/backend/projectify/storefront/templates/storefront/solutions/personal-use.html index 9c37d8dab..0e88d0fd7 100644 --- a/backend/projectify/storefront/templates/storefront/solutions/personal-use.html +++ b/backend/projectify/storefront/templates/storefront/solutions/personal-use.html @@ -9,7 +9,7 @@ {% block solutions_content %}
    {% trans "Personal solutions" as title %} - {% trans "How people can use Projectify for everyday life, whether it be keeping track fo your health or creating an itinerary for a trip." as text %} + {% trans "How people can use Projectify for everyday life, whether it be keeping track of your health or creating an itinerary for a trip." as text %} {% trans "Our mascot Poly thinking about all the things they want to do, among them being running, taking pictures and eating something tasty, each thing symbolized by a thought bubble" as alt %} {% include "storefront/common/solutions_header.html" with title=title text=text src="solutions/hero-remote-work.png" alt=alt %}
    diff --git a/backend/projectify/storefront/templates/storefront/solutions/project-management.html b/backend/projectify/storefront/templates/storefront/solutions/project-management.html index 5da49d695..61d921e79 100644 --- a/backend/projectify/storefront/templates/storefront/solutions/project-management.html +++ b/backend/projectify/storefront/templates/storefront/solutions/project-management.html @@ -23,7 +23,7 @@ {% trans "An illustration showing a collapsed dashboard side bar and various team members and labels" as alt %} {% include "storefront/common/solutions_detail_item.html" with title=title text=text src="solutions/filters.png" alt=alt %} {% trans "Permissions to control access" as title %} - {% trans "Make sure nothing important gets deleted. With permission roles - Owner, Maintainer, Contributor and Observer, you can be safe in knowing there won't be any accidentally data loss." as text %} + {% trans "Make sure nothing important gets deleted. With permission roles - Owner, Maintainer, Contributor and Observer, you can be safe in knowing there won't be any accidental data loss." as text %} {% trans "An illustration of a settings screen showing team members belonging to a team member and their role within the workspace. The illustration also shows a button allowing filtering by role and another button letting a user invite new team members" as alt %} {% include "storefront/common/solutions_detail_item.html" with title=title text=text src="solutions/project-management-permissions.png" alt=alt imgPosition="left" %}
    From 5257466e7fcfdb0e2d0b2ed6b31c7565bed175f2 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Sun, 15 Feb 2026 09:32:05 +0900 Subject: [PATCH 26/43] BE: Refactor onboarding about you --- .../templates/onboarding/about_you.html | 41 +++++++++++++------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/backend/projectify/onboarding/templates/onboarding/about_you.html b/backend/projectify/onboarding/templates/onboarding/about_you.html index b5ace6b4a..c37acaeeb 100644 --- a/backend/projectify/onboarding/templates/onboarding/about_you.html +++ b/backend/projectify/onboarding/templates/onboarding/about_you.html @@ -10,19 +10,36 @@ class="col-span-1 flex shrink grow flex-col gap-16 px-12 py-20 pb-8"> {% csrf_token %} {% include "onboarding/common/form_description.html" with title=_("About you") text_1=_("Tell us your preferred name. You can also keep it empty and continue by clicking the button below.") %} -
    -
    {{ form }}
    -
    -
    -
    - -
    -
    + {{ form }} + {% include "projectify/forms/submit.html" with text=_("Continue") %} + {% endblock onboarding_content %} From baab4bce5a16905d77dce24d6e5fb16d67549488 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Sun, 15 Feb 2026 09:41:32 +0900 Subject: [PATCH 27/43] Bring back alpine This reverts commit e597d958711cf0158b61eb935b84b9aa48cfbee3. --- backend/projectify/static/alpine.min.js | 7 +++++++ backend/projectify/templates/base.html | 1 + 2 files changed, 8 insertions(+) create mode 100644 backend/projectify/static/alpine.min.js diff --git a/backend/projectify/static/alpine.min.js b/backend/projectify/static/alpine.min.js new file mode 100644 index 000000000..2b138a0fc --- /dev/null +++ b/backend/projectify/static/alpine.min.js @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: Copyright © 2019-2021 Caleb Porzio and contributors +// SPDX-License-Identifier: MIT +(()=>{var nt=!1,it=!1,W=[],ot=-1;function Ut(e){Rn(e)}function Rn(e){W.includes(e)||W.push(e),Mn()}function Wt(e){let t=W.indexOf(e);t!==-1&&t>ot&&W.splice(t,1)}function Mn(){!it&&!nt&&(nt=!0,queueMicrotask(Nn))}function Nn(){nt=!1,it=!0;for(let e=0;ee.effect(t,{scheduler:r=>{st?Ut(r):r()}}),at=e.raw}function ct(e){N=e}function Yt(e){let t=()=>{};return[n=>{let i=N(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),$(i))},i},()=>{t()}]}function ve(e,t){let r=!0,n,i=N(()=>{let o=e();JSON.stringify(o),r?n=o:queueMicrotask(()=>{t(o,n),n=o}),r=!1});return()=>$(i)}var Xt=[],Zt=[],Qt=[];function er(e){Qt.push(e)}function te(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Zt.push(t))}function Ae(e){Xt.push(e)}function Oe(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function lt(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}function tr(e){for(e._x_effects?.forEach(Wt);e._x_cleanups?.length;)e._x_cleanups.pop()()}var ut=new MutationObserver(mt),ft=!1;function ue(){ut.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ft=!0}function dt(){kn(),ut.disconnect(),ft=!1}var le=[];function kn(){let e=ut.takeRecords();le.push(()=>e.length>0&&mt(e));let t=le.length;queueMicrotask(()=>{if(le.length===t)for(;le.length>0;)le.shift()()})}function m(e){if(!ft)return e();dt();let t=e();return ue(),t}var pt=!1,Se=[];function rr(){pt=!0}function nr(){pt=!1,mt(Se),Se=[]}function mt(e){if(pt){Se=Se.concat(e);return}let t=[],r=new Set,n=new Map,i=new Map;for(let o=0;o{s.nodeType===1&&s._x_marker&&r.add(s)}),e[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||t.push(s)}})),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{lt(s,o)}),n.forEach((o,s)=>{Xt.forEach(a=>a(s,o))});for(let o of r)t.some(s=>s.contains(o))||Zt.forEach(s=>s(o));for(let o of t)o.isConnected&&Qt.forEach(s=>s(o));t=null,r=null,n=null,i=null}function Ce(e){return z(B(e))}function k(e,t,r){return e._x_dataStack=[t,...B(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function B(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?B(e.host):e.parentNode?B(e.parentNode):[]}function z(e){return new Proxy({objects:e},Dn)}var Dn={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(r=>Object.prototype.hasOwnProperty.call(r,t)||Reflect.has(r,t))},get({objects:e},t,r){return t=="toJSON"?Pn:Reflect.get(e.find(n=>Reflect.has(n,t))||{},t,r)},set({objects:e},t,r,n){let i=e.find(s=>Object.prototype.hasOwnProperty.call(s,t))||e[e.length-1],o=Object.getOwnPropertyDescriptor(i,t);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,t,r)}};function Pn(){return Reflect.ownKeys(this).reduce((t,r)=>(t[r]=Reflect.get(this,r),t),{})}function Te(e){let t=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(e,c,o):t(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(e)}function Re(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return e(this.initialValue,()=>In(n,i),s=>ht(n,i,s),i,o)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function In(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function ht(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),ht(e[t[0]],t.slice(1),r)}}var ir={};function y(e,t){ir[e]=t}function fe(e,t){let r=Ln(t);return Object.entries(ir).forEach(([n,i])=>{Object.defineProperty(e,`$${n}`,{get(){return i(t,r)},enumerable:!1})}),e}function Ln(e){let[t,r]=_t(e),n={interceptor:Re,...t};return te(e,r),n}function or(e,t,r,...n){try{return r(...n)}catch(i){re(i,e,t)}}function re(e,t,r=void 0){e=Object.assign(e??{message:"No error message given."},{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message} + +${r?'Expression: "'+r+`" + +`:""}`,t),setTimeout(()=>{throw e},0)}var Me=!0;function ke(e){let t=Me;Me=!1;let r=e();return Me=t,r}function R(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return sr(...e)}var sr=xt;function ar(e){sr=e}function xt(e,t){let r={};fe(r,e);let n=[r,...B(e)],i=typeof t=="function"?$n(n,t):Fn(n,t,e);return or.bind(null,e,t,i)}function $n(e,t){return(r=()=>{},{scope:n={},params:i=[]}={})=>{let o=t.apply(z([n,...e]),i);Ne(r,o)}}var gt={};function jn(e,t){if(gt[e])return gt[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${e}`}),s}catch(s){return re(s,t,e),Promise.resolve()}})();return gt[e]=o,o}function Fn(e,t,r){let n=jn(t,r);return(i=()=>{},{scope:o={},params:s=[]}={})=>{n.result=void 0,n.finished=!1;let a=z([o,...e]);if(typeof n=="function"){let c=n(n,a).catch(l=>re(l,r,t));n.finished?(Ne(i,n.result,a,s,r),n.result=void 0):c.then(l=>{Ne(i,l,a,s,r)}).catch(l=>re(l,r,t)).finally(()=>n.result=void 0)}}}function Ne(e,t,r,n,i){if(Me&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>Ne(e,s,r,n)).catch(s=>re(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}var wt="x-";function C(e=""){return wt+e}function cr(e){wt=e}var De={};function d(e,t){return De[e]=t,{before(r){if(!De[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${e}\` will use the default order of execution`);return}let n=G.indexOf(r);G.splice(n>=0?n:G.indexOf("DEFAULT"),0,e)}}}function lr(e){return Object.keys(De).includes(e)}function pe(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=Et(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(dr((o,s)=>n[o]=s)).filter(mr).map(zn(n,r)).sort(Kn).map(o=>Bn(e,o))}function Et(e){return Array.from(e).map(dr()).filter(t=>!mr(t))}var yt=!1,de=new Map,ur=Symbol();function fr(e){yt=!0;let t=Symbol();ur=t,de.set(t,[]);let r=()=>{for(;de.get(t).length;)de.get(t).shift()();de.delete(t)},n=()=>{yt=!1,r()};e(r),n()}function _t(e){let t=[],r=a=>t.push(a),[n,i]=Yt(e);return t.push(i),[{Alpine:K,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:R.bind(R,e)},()=>t.forEach(a=>a())]}function Bn(e,t){let r=()=>{},n=De[t.type]||r,[i,o]=_t(e);Oe(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),yt?de.get(ur).push(n):n())};return s.runCleanups=o,s}var Pe=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),Ie=e=>e;function dr(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=pr.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var pr=[];function ne(e){pr.push(e)}function mr({name:e}){return hr().test(e)}var hr=()=>new RegExp(`^${wt}([^:^.]+)\\b`);function zn(e,t){return({name:r,value:n})=>{let i=r.match(hr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var bt="DEFAULT",G=["ignore","ref","data","id","anchor","bind","init","for","model","modelable","transition","show","if",bt,"teleport"];function Kn(e,t){let r=G.indexOf(e.type)===-1?bt:e.type,n=G.indexOf(t.type)===-1?bt:t.type;return G.indexOf(r)-G.indexOf(n)}function J(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}function D(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>D(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)D(n,t,!1),n=n.nextElementSibling}function E(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var _r=!1;function gr(){_r&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),_r=!0,document.body||E("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's ` + {% endblock onboarding_content %} From 8aecf40c237dafc826524be68c8b1b5918a8dc50 Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Sun, 15 Feb 2026 10:41:58 +0900 Subject: [PATCH 32/43] BE: Add cute project title preview --- .../templates/onboarding/new_project.html | 55 +++++++++++++------ backend/projectify/onboarding/views.py | 12 ++-- 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/backend/projectify/onboarding/templates/onboarding/new_project.html b/backend/projectify/onboarding/templates/onboarding/new_project.html index a7b5b5ea5..59d82aa0c 100644 --- a/backend/projectify/onboarding/templates/onboarding/new_project.html +++ b/backend/projectify/onboarding/templates/onboarding/new_project.html @@ -2,6 +2,7 @@ {# SPDX-License-Identifier: AGPL-3.0-or-later #} {% extends "onboarding_base.html" %} {% load i18n %} +{% load projectify %} {% block title %} {% translate "Add your first project - Projectify" %} {% endblock title %} @@ -16,29 +17,51 @@

    {% trans "Add your first proje

    {% blocktrans %}It looks like you already have a project called "{{ project }}". Would you like to continue adding a task for it?{% endblocktrans %}

    -

    - {% blocktrans %}Continue adding a task to "{{ project }}"{% endblocktrans %} -

    + {% anchor href="onboarding:new_task" project_uuid=project.uuid label=_("Continue adding a task to this project") %} {% else %}

    {% trans "You can create unlimited projects per workspace." %}

    {% trans "Projects help you to focus on different projects you may be working on." %}

    {% endif %}

    -
    -
    {{ form }}
    -
    -
    -
    - -
    -
    - {% include "onboarding/common/step_counter.html" with step=2 step_count="5" %} + {{ form }} + {% include "projectify/forms/submit.html" with text=_("Continue") %} + {% include "onboarding/common/step_counter.html" with step=2 step_count=5 %} + {% endblock onboarding_content %} diff --git a/backend/projectify/onboarding/views.py b/backend/projectify/onboarding/views.py index dd7f12a19..5a95c7907 100644 --- a/backend/projectify/onboarding/views.py +++ b/backend/projectify/onboarding/views.py @@ -29,6 +29,7 @@ team_member_find_for_workspace, ) from projectify.workspace.selectors.workspace import ( + WorkspaceDetailQuerySet, workspace_find_by_workspace_uuid, workspace_find_for_user, ) @@ -156,13 +157,10 @@ def new_project( On error: Show project creation form with errors. """ workspace = workspace_find_by_workspace_uuid( - workspace_uuid=workspace_uuid, who=request.user + workspace_uuid=workspace_uuid, who=request.user, qs=WorkspaceDetailQuerySet ) if workspace is None: raise Http404(_("Workspace not found")) - projects = project_find_by_workspace_uuid( - workspace_uuid=workspace_uuid, who=request.user, archived=False - ) if request.method == "POST": form = ProjectForm(request.POST) @@ -178,7 +176,11 @@ def new_project( else: form = ProjectForm() - context = {"form": form, "project": projects.first()} + context = { + "form": form, + "workspace": workspace, + "project": workspace.project_set.first(), + } return render(request, "onboarding/new_project.html", context) From 50e4f7beca30a1a6ea8a3209a2dd92da2e12534a Mon Sep 17 00:00:00 2001 From: Justus Perlwitz Date: Sun, 15 Feb 2026 11:32:13 +0900 Subject: [PATCH 33/43] BE: Improve onboarding new task --- .../templates/onboarding/new_task.html | 82 +++++++++++++------ backend/projectify/onboarding/views.py | 6 +- 2 files changed, 64 insertions(+), 24 deletions(-) diff --git a/backend/projectify/onboarding/templates/onboarding/new_task.html b/backend/projectify/onboarding/templates/onboarding/new_task.html index 70051b6cc..bb54b8c51 100644 --- a/backend/projectify/onboarding/templates/onboarding/new_task.html +++ b/backend/projectify/onboarding/templates/onboarding/new_task.html @@ -1,7 +1,8 @@ -{# SPDX-FileCopyrightText: 2025 JWP Consulting GK #} +{# SPDX-FileCopyrightText: 2025-2026 JWP Consulting GK #} {# SPDX-License-Identifier: AGPL-3.0-or-later #} {% extends "onboarding_base.html" %} {% load i18n %} +{% load projectify %} {% block title %} {% translate "Create your first task - Projectify" %} {% endblock title %} @@ -11,30 +12,65 @@ {% csrf_token %}

    {% trans "What is a task you’d like to complete?" %}

    -
    -

    - {% if section %} - {% blocktrans %}It looks like you already have a section called "{{section}}". We will now create a task and place it into that section.{% endblocktrans %} - {% else %} - {% blocktrans %}This task will be placed in a section called "{{section_title}}".{% endblocktrans %} - {% endif %} -

    -

    {% trans "Tasks can be further divided into sub tasks and contain detailed descriptions." %}

    -
    -
    -
    -
    {{ form }}
    +

    + {% if section %} + {% blocktrans %}It looks like you already have a section called "{{section}}". We will now create a task and place it into that section.{% endblocktrans %} + {% else %} + {% blocktrans %}This task will be placed in a section called "{{section_title}}".{% endblocktrans %} + {% endif %} +

    +

    {% trans "Tasks can be further divided into sub tasks and contain detailed descriptions." %}

    -
    -
    - + {{ form }} + {% include "projectify/forms/submit.html" with text=_("Continue") %} + {% include "onboarding/common/step_counter.html" with step=3 step_count=5 %} + +