# NOTE: This file is used for benchmarking. Before editing this file, # please read through these: # - `benchmarks/README` # - https://github.com/django-components/django-components/pull/999 import difflib import json from datetime import date, datetime, timedelta from dataclasses import dataclass, field, MISSING from enum import Enum from inspect import signature from pathlib import Path from types import MappingProxyType from typing import ( Any, Callable, Dict, Iterable, List, Literal, NamedTuple, Optional, Tuple, Type, TypedDict, TypeVar, Union, ) import django from django import forms from django.conf import settings from django.http import HttpRequest from django.middleware import csrf from django.template import Context, Template from django.template.defaultfilters import title from django.utils.safestring import mark_safe from django.utils.timezone import now from django.contrib.humanize.templatetags.humanize import naturaltime from django.template.defaulttags import register as default_library from django_components import types, registry # DO NOT REMOVE - See https://github.com/django-components/django-components/pull/999 # ----------- IMPORTS END ------------ # # This variable is overridden by the benchmark runner CONTEXT_MODE: Literal["django", "isolated"] = "isolated" if not settings.configured: settings.configure( BASE_DIR=Path(__file__).resolve().parent, INSTALLED_APPS=["django_components"], TEMPLATES=[ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [ "tests/templates/", "tests/components/", # Required for template relative imports in tests ], "OPTIONS": { "builtins": [ "django_components.templatetags.component_tags", ] }, } ], COMPONENTS={ "autodiscover": False, "context_behavior": CONTEXT_MODE, }, MIDDLEWARE=[], DATABASES={ "default": { "ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:", } }, SECRET_KEY="secret", ROOT_URLCONF="django_components.urls", ) django.setup() else: settings.COMPONENTS["context_behavior"] = CONTEXT_MODE ##################################### # # IMPLEMENTATION START # ##################################### templates_cache: Dict[int, Template] = {} def lazy_load_template(template: str) -> Template: template_hash = hash(template) if template_hash in templates_cache: return templates_cache[template_hash] else: template_instance = Template(template) templates_cache[template_hash] = template_instance return template_instance ##################################### # RENDER ENTRYPOINT ##################################### def gen_render_data(): data = load_project_data_from_json(data_json) # Generate Request and User users = data.pop("users") user = users[0] bookmarks: List[ProjectBookmark] = [ { "id": 82, "project": data["project"], "text": "Test bookmark", "url": "http://localhost:8000/bookmarks/9/create", "attachment": None, } ] request = HttpRequest() request.user = user request.method = "GET" request.path = "/projects/1" data["layout_data"] = ProjectLayoutData( bookmarks=bookmarks, project=data["project"], active_projects=[data["project"]], request=request, ) return data def render(data): # Render result = project_page( Context(), ProjectPageData(**data) ) return result ##################################### # DATA ##################################### data_json = """ { "project": { "pk": 1, "fields": { "name": "Project Name", "organization": 1, "status": "INPROGRESS", "start_date": "2022-02-06", "end_date": "2024-02-07" } }, "project_tags": [], "phases": [ { "pk": 8, "fields": { "project": 1, "phase_template": 3 } }, { "pk": 7, "fields": { "project": 1, "phase_template": 4 } }, { "pk": 6, "fields": { "project": 1, "phase_template": 5 } }, { "pk": 5, "fields": { "project": 1, "phase_template": 6 } }, { "pk": 4, "fields": { "project": 1, "phase_template": 2 } } ], "notes_1": [ { "pk": 1, "fields": { "created": "2025-02-07T08:59:58.689Z", "modified": "2025-02-07T08:59:58.689Z", "project": 1, "text": "Test note 1" } }, { "pk": 2, "fields": { "created": "2025-02-07T08:59:58.689Z", "modified": "2025-02-07T08:59:58.689Z", "project": 1, "text": "Test note 2" } } ], "comments_by_notes_1": { "1": [ { "pk": 3, "fields": { "parent": 1, "notes": "Test note one two three", "modified_by": 1 } }, { "pk": 4, "fields": { "parent": 1, "notes": "Test note 2", "modified_by": 1 } } ] }, "notes_2": [ { "pk": 1, "fields": { "created": "2024-02-07T11:20:49.085Z", "modified": "2024-02-07T11:20:55.003Z", "project": 1, "text": "Test note x" } } ], "comments_by_notes_2": { "1": [ { "pk": 1, "fields": { "parent": 1, "text": "Test note 6", "modified_by": 1 } }, { "pk": 2, "fields": { "parent": 1, "text": "Test note 5", "modified_by": 1 } }, { "pk": 4, "fields": { "parent": 1, "text": "Test note 4", "modified_by": 1 } }, { "pk": 6, "fields": { "parent": 1, "text": "Test note 3", "modified_by": 1 } } ] }, "notes_3": [ { "pk": 2, "fields": { "created": "2024-02-07T11:20:49.085Z", "modified": "2024-02-07T11:20:55.003Z", "project": 1, "text": "Test note 2" } } ], "comments_by_notes_3": { "2": [ { "pk": 1, "fields": { "parent": 2, "text": "Test note 1", "modified_by": 1 } }, { "pk": 3, "fields": { "parent": 2, "text": "Test note 0", "modified_by": 1 } } ] }, "roles_with_users": [ { "pk": 6, "fields": { "user": 2, "project": 1, "name": "Assistant" } }, { "pk": 7, "fields": { "user": 2, "project": 1, "name": "Owner" } } ], "contacts": [], "outputs": [ [ { "pk": 14, "fields": { "name": "Lorem ipsum 16", "description": "", "completed": false, "phase": 8, "dependency": null } }, [], [] ], [ { "pk": 15, "fields": { "name": "Lorem ipsum 15", "description": "", "completed": false, "phase": 8, "dependency": null } }, [], [] ], [ { "pk": 16, "fields": { "name": "Lorem ipsum 14", "description": "", "completed": false, "phase": 8, "dependency": null } }, [], [] ], [ { "pk": 17, "fields": { "name": "Lorem ipsum 13", "description": "", "completed": false, "phase": 8, "dependency": null } }, [], [] ], [ { "pk": 18, "fields": { "name": "Lorem ipsum 12", "description": "", "completed": true, "phase": 4, "dependency": null } }, [ [ { "pk": 19, "fields": { "text": "Test bookmark", "url": "http://localhost:8000/create/bookmmarks/9/", "created_by": 1, "output": 18 } }, [] ] ], [] ], [ { "pk": 20, "fields": { "name": "Lorem ipsum 11", "description": "", "completed": false, "phase": 7, "dependency": 14 } }, [], [ [ { "pk": 14, "fields": { "name": "Lorem ipsum 10", "description": "", "completed": false, "phase": 8, "dependency": null } }, [] ] ] ], [ { "pk": 21, "fields": { "name": "Lorem ipsum 9", "description": "", "completed": false, "phase": 7, "dependency": null } }, [], [] ], [ { "pk": 22, "fields": { "name": "Lorem ipsum 8", "description": "", "completed": false, "phase": 7, "dependency": null } }, [], [] ], [ { "pk": 23, "fields": { "name": "Lorem ipsum 7", "description": "", "completed": false, "phase": 7, "dependency": null } }, [], [] ], [ { "pk": 24, "fields": { "name": "Lorem ipsum 6", "description": "", "completed": false, "phase": 7, "dependency": null } }, [], [] ], [ { "pk": 25, "fields": { "name": "Lorem ipsum 5", "description": "", "completed": false, "phase": 6, "dependency": null } }, [], [] ], [ { "pk": 26, "fields": { "name": "Lorem ipsum 4", "description": "", "completed": false, "phase": 6, "dependency": null } }, [], [] ], [ { "pk": 27, "fields": { "name": "Lorem ipsum 3", "description": "", "completed": false, "phase": 5, "dependency": null } }, [], [] ], [ { "pk": 28, "fields": { "name": "Lorem ipsum 2", "description": "", "completed": false, "phase": 7, "dependency": 14 } }, [], [ [ { "pk": 14, "fields": { "name": "Lorem ipsum 1", "description": "", "completed": false, "phase": 8, "dependency": null } }, [] ] ] ] ], "status_updates": [], "user_is_project_member": true, "user_is_project_owner": true, "phase_titles": { "PHASE_0": "Phase 0", "PHASE_1": "Phase 1", "PHASE_2": "Phase 2", "PHASE_3": "Phase 3", "PHASE_4": "Phase 4", "PHASE_5": "Phase 5", "LEGACY": "Legacy" }, "users": [ { "pk": 2, "fields": { "name": "UserName", "is_staff": true } } ], "organizations": [ { "pk": 1, "fields": { "created": "2025-02-07T16:27:49.837Z", "modified": "2025-02-07T16:27:49.837Z", "name": "Org Name" } } ], "phase_templates": [ { "pk": 3, "fields": { "created": "2025-02-07T16:27:49.837Z", "modified": "2025-02-07T16:27:49.837Z", "name": "Phase 3", "description": "## Phase 3", "type": "PHASE_3" } }, { "pk": 4, "fields": { "created": "2025-02-07T16:27:49.837Z", "modified": "2025-02-07T16:27:49.837Z", "name": "Phase 2", "description": "## Phase 2", "type": "PHASE_2" } }, { "pk": 5, "fields": { "created": "2025-02-07T16:27:49.837Z", "modified": "2025-02-07T16:27:49.837Z", "name": "Phase 4", "description": "## Phase 4", "type": "PHASE_4" } }, { "pk": 6, "fields": { "created": "2025-02-07T16:27:49.837Z", "modified": "2025-02-07T16:27:49.837Z", "name": "Phase 5", "description": "## Phase 5", "type": "PHASE_5" } }, { "pk": 2, "fields": { "created": "2025-02-07T16:27:49.837Z", "modified": "2025-02-07T16:27:49.837Z", "name": "Phase 1", "description": "## Phase 1", "type": "PHASE_1" } } ] } """ ##################################### # DATA LOADER ##################################### def load_project_data_from_json(contents: str) -> dict: """ Loads project data from JSON and resolves references between objects. Returns the data with all resolvable references replaced with actual object references. """ data = json.loads(contents) # First create lookup tables for objects that will be referenced users_by_id = {user["pk"]: {"id": user["pk"], **user["fields"]} for user in data.get("users", [])} def _get_user(user_id: int): return users_by_id[user_id] if user_id in users_by_id else data.get("users", [])[0] organizations_by_id = {org["pk"]: {"id": org["pk"], **org["fields"]} for org in data.get("organizations", [])} phase_templates_by_id = {pt["pk"]: {"id": pt["pk"], **pt["fields"]} for pt in data.get("phase_templates", [])} # 1. Resolve project's organization reference project = {"id": data["project"]["pk"], **data["project"]["fields"]} if "organization" in project: org_id = project.pop("organization") # Remove the ID field project["organization"] = organizations_by_id[org_id] # Add the reference # 2. Project tags - no changes needed project_tags = data["project_tags"] # 3. Resolve phases' references phases = [] phases_by_id = {} # We'll need this for resolving output references later for phase_data in data["phases"]: phase = {"id": phase_data["pk"], **phase_data["fields"]} if "project" in phase: phase["project"] = project if "phase_template" in phase: template_id = phase.pop("phase_template") phase["phase_template"] = phase_templates_by_id[template_id] phases.append(phase) phases_by_id[phase["id"]] = phase # 4. Resolve notes_1 references notes_1 = [] notes_1_by_id = {} # We'll need this for resolving notes references for note_data in data["notes_1"]: note = {"id": note_data["pk"], **note_data["fields"]} if "project" in note: note["project"] = project notes_1.append(note) notes_1_by_id[note["id"]] = note # 5. Resolve comments_by_notes_1 references comments_by_notes_1 = {} for note_id, comments_list in data["comments_by_notes_1"].items(): resolved_comments = [] for comment_data in comments_list: comment = {"id": comment_data["pk"], **comment_data["fields"]} if "modified_by" in comment: comment["modified_by"] = _get_user(comment["modified_by"]) if "parent" in comment: comment["parent"] = notes_1_by_id[comment["parent"]] resolved_comments.append(comment) comments_by_notes_1[note_id] = resolved_comments # 6. Resolve notes_2' references notes_2 = [] notes_2_by_id = {} # We'll need this for resolving notes references for note_data in data["notes_2"]: note = {"id": note_data["pk"], **note_data["fields"]} if "project" in note: note["project"] = project notes_2.append(note) notes_2_by_id[note["id"]] = note # 7. Resolve comments_by_notes_2 references comments_by_notes_2 = {} for note_id, comments_list in data["comments_by_notes_2"].items(): resolved_comments = [] for comment_data in comments_list: comment = {"id": comment_data["pk"], **comment_data["fields"]} if "modified_by" in comment: comment["modified_by"] = _get_user(comment["modified_by"]) if "parent" in comment: comment["parent"] = notes_2_by_id[comment["parent"]] resolved_comments.append(comment) comments_by_notes_2[note_id] = resolved_comments # 8. Resolve notes_3 references notes_3 = [] notes_3_by_id = {} # We'll need this for resolving notes references for note_data in data["notes_3"]: note = {"id": note_data["pk"], **note_data["fields"]} if "project" in note: note["project"] = project notes_3.append(note) notes_3_by_id[note["id"]] = note # 9. Resolve comments_by_notes_3 references comments_by_notes_3 = {} for note_id, comments_list in data["comments_by_notes_3"].items(): resolved_comments = [] for comment_data in comments_list: comment = {"id": comment_data["pk"], **comment_data["fields"]} if "modified_by" in comment: comment["modified_by"] = _get_user(comment["modified_by"]) if "parent" in comment: comment["parent"] = notes_3_by_id[comment["parent"]] resolved_comments.append(comment) comments_by_notes_3[note_id] = resolved_comments # 10. Resolve roles_with_users references roles = [] for role_data in data["roles_with_users"]: role = {"id": role_data["pk"], **role_data["fields"]} if "project" in role: role["project"] = project if "user" in role: role["user"] = _get_user(role["user"]) roles.append(role) # 11. Contacts - EMPTY, so no changes needed contacts = data["contacts"] # 12. Resolve outputs references resolved_outputs = [] outputs_by_id = {} # For resolving dependencies # First pass: Create all output objects and build lookup for output_tuple in data["outputs"]: output_data = output_tuple[0] output = {"id": output_data["pk"], **output_data["fields"]} if "phase" in output: output["phase"] = phases_by_id[output["phase"]] outputs_by_id[output["id"]] = output # Second pass: Process each output with its attachments and dependencies for output_tuple in data["outputs"]: output_data, attachments_data, dependencies_data = output_tuple output = outputs_by_id[output_data["pk"]] # Process attachments resolved_attachments = [] for attachment_tuple in attachments_data: attachment_data = attachment_tuple[0] attachment = {"id": attachment_data["pk"], **attachment_data["fields"]} if "created_by" in attachment: attachment["created_by"] = _get_user(attachment["created_by"]) if "output" in attachment: attachment["output"] = outputs_by_id[attachment["output"]] # Keep tags as is resolved_attachments.append((attachment, attachment_tuple[1])) # Process dependencies resolved_dependencies = [] for dep_tuple in dependencies_data: dep_data = dep_tuple[0] dep_output = outputs_by_id[dep_data["pk"]] # Keep the tuple structure but with resolved references resolved_dependencies.append((dep_output, dep_tuple[1])) resolved_outputs.append((output, resolved_attachments, resolved_dependencies)) return { "project": project, "project_tags": project_tags, "phases": phases, "notes_1": notes_1, "comments_by_notes_1": comments_by_notes_1, "notes_2": notes_2, "comments_by_notes_2": comments_by_notes_2, "notes_3": notes_3, "comments_by_notes_3": comments_by_notes_3, "roles_with_users": roles, "contacts": contacts, "outputs": resolved_outputs, "status_updates": data["status_updates"], "user_is_project_member": data["user_is_project_member"], "user_is_project_owner": data["user_is_project_owner"], "phase_titles": data["phase_titles"], "users": data["users"], } ##################################### # TYPES ##################################### class User(TypedDict): id: int name: str class Organization(TypedDict): id: int name: str class Project(TypedDict): id: int name: str organization: Organization status: str start_date: date end_date: date class ProjectRole(TypedDict): id: int user: User project: Project name: str class ProjectBookmark(TypedDict): id: int project: Project text: str url: str attachment: Optional["ProjectOutputAttachment"] class ProjectStatusUpdate(TypedDict): id: int project: Project text: str modified_by: User modified: str class ProjectContact(TypedDict): id: int project: Project link_id: str name: str job: str class PhaseTemplate(TypedDict): id: int name: str description: str type: str class ProjectPhase(TypedDict): id: int project: Project phase_template: PhaseTemplate class ProjectOutput(TypedDict): id: int name: str description: str completed: bool phase: ProjectPhase dependency: Optional["ProjectOutput"] class ProjectOutputAttachment(TypedDict): id: int text: str url: str created_by: User output: ProjectOutput class ProjectNote(TypedDict): id: int project: Project text: str created: str class ProjectNoteComment(TypedDict): id: int parent: ProjectNote text: str modified_by: User modified: str ##################################### # CONSTANTS ##################################### FORM_SHORT_TEXT_MAX_LEN = 255 # This allows us to compare Enum values against strings class StrEnum(str, Enum): pass class TagResourceType(StrEnum): PROJECT = "PROJECT" PROJECT_BOOKMARK = "PROJECT_BOOKMARK" PROJECT_OUTPUT = "PROJECT_OUTPUT" PROJECT_OUTPUT_ATTACHMENT = "PROJECT_OUTPUT_ATTACHMENT" PROJECT_TEMPLATE = "PROJECT_TEMPLATE" class ProjectPhaseType(StrEnum): PHASE_1 = "PHASE_1" PHASE_2 = "PHASE_2" PHASE_3 = "PHASE_3" PHASE_4 = "PHASE_4" PHASE_5 = "PHASE_5" class TagTypeMeta(NamedTuple): allowed_values: Tuple[str, ...] # Additional metadata for Tags # # NOTE: We use MappingProxyType as an immutable dict. # See https://stackoverflow.com/questions/2703599 TAG_TYPE_META = MappingProxyType( { TagResourceType.PROJECT: TagTypeMeta( allowed_values=( "Tag 1", "Tag 2", "Tag 3", "Tag 4", ), ), TagResourceType.PROJECT_BOOKMARK: TagTypeMeta( allowed_values=( "Tag 5", "Tag 6", "Tag 7", "Tag 8", ), ), TagResourceType.PROJECT_OUTPUT: TagTypeMeta( allowed_values=tuple(), ), TagResourceType.PROJECT_OUTPUT_ATTACHMENT: TagTypeMeta( allowed_values=( "Tag 9", "Tag 10", "Tag 11", "Tag 12", "Tag 13", "Tag 14", "Tag 15", "Tag 16", "Tag 17", "Tag 18", "Tag 19", "Tag 20", ), ), TagResourceType.PROJECT_TEMPLATE: TagTypeMeta( allowed_values=("Tag 21",), ), } ) class ProjectOutputDef(NamedTuple): title: str description: Optional[str] = None dependency: Optional[str] = None class ProjectPhaseMeta(NamedTuple): type: ProjectPhaseType outputs: List[ProjectOutputDef] # This constant decides in which order the project phases are shown, # as well as what kind of name of description they have. # # NOTE: We use MappingProxyType as an immutable dict. # See https://stackoverflow.com/questions/2703599 PROJECT_PHASES_META = MappingProxyType( { ProjectPhaseType.PHASE_1: ProjectPhaseMeta( type=ProjectPhaseType.PHASE_1, outputs=[ ProjectOutputDef(title="Lorem ipsum 0"), ], ), ProjectPhaseType.PHASE_2: ProjectPhaseMeta( type=ProjectPhaseType.PHASE_2, outputs=[ ProjectOutputDef(title="Lorem ipsum 1"), ProjectOutputDef(title="Lorem ipsum 2"), ProjectOutputDef(title="Lorem ipsum 3"), ProjectOutputDef(title="Lorem ipsum 4"), ], ), ProjectPhaseType.PHASE_3: ProjectPhaseMeta( type=ProjectPhaseType.PHASE_3, outputs=[ ProjectOutputDef( title="Lorem ipsum 6", dependency="Lorem ipsum 1", ), ProjectOutputDef( title="Lorem ipsum 7", dependency="Lorem ipsum 1", ), ProjectOutputDef(title="Lorem ipsum 8"), ProjectOutputDef(title="Lorem ipsum 9"), ProjectOutputDef(title="Lorem ipsum 10"), ProjectOutputDef(title="Lorem ipsum 11"), ], ), ProjectPhaseType.PHASE_4: ProjectPhaseMeta( type=ProjectPhaseType.PHASE_4, outputs=[ ProjectOutputDef(title="Lorem ipsum 12"), ProjectOutputDef(title="Lorem ipsum 13"), ], ), ProjectPhaseType.PHASE_5: ProjectPhaseMeta( type=ProjectPhaseType.PHASE_5, outputs=[ ProjectOutputDef(title="Lorem ipsum 14"), ], ), } ) ##################################### # THEME ##################################### ThemeColor = Literal["default", "error", "success", "alert", "info"] ThemeVariant = Literal["primary", "secondary"] VARIANTS = ["primary", "secondary"] class ThemeStylingUnit(NamedTuple): """ Smallest unit of info, this class defines a specific styling of a specific component in a specific state. E.g. styling of a disabled "Error" button. """ color: str """CSS class(es) specifying color""" css: str = "" """Other CSS classes not specific to color""" class ThemeStylingVariant(NamedTuple): """ Collection of styling combinations that are meaningful as a group. E.g. all "error" variants - primary, disabled, secondary, ... """ primary: ThemeStylingUnit primary_disabled: ThemeStylingUnit secondary: ThemeStylingUnit secondary_disabled: ThemeStylingUnit class Theme(NamedTuple): """Class for defining a styling and color theme for the app.""" default: ThemeStylingVariant error: ThemeStylingVariant alert: ThemeStylingVariant success: ThemeStylingVariant info: ThemeStylingVariant sidebar: str sidebar_link: str background: str tab_active: str tab_text_active: str tab_text_inactive: str check_interactive: str check_static: str check_outline: str _secondary_btn_styling = "ring-1 ring-inset" theme = Theme( default=ThemeStylingVariant( primary=ThemeStylingUnit( color="bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600 transition" ), primary_disabled=ThemeStylingUnit(color="bg-blue-300 text-blue-50 focus-visible:outline-blue-600 transition"), secondary=ThemeStylingUnit( color="bg-white text-gray-800 ring-gray-300 hover:bg-gray-100 focus-visible:outline-gray-600 transition", css=_secondary_btn_styling, ), secondary_disabled=ThemeStylingUnit( color="bg-white text-gray-300 ring-gray-300 focus-visible:outline-gray-600 transition", css=_secondary_btn_styling, ), ), error=ThemeStylingVariant( primary=ThemeStylingUnit(color="bg-red-600 text-white hover:bg-red-500 focus-visible:outline-red-600"), primary_disabled=ThemeStylingUnit(color="bg-red-300 text-white focus-visible:outline-red-600"), secondary=ThemeStylingUnit( color="bg-white text-red-600 ring-red-300 hover:bg-red-100 focus-visible:outline-red-600", css=_secondary_btn_styling, ), secondary_disabled=ThemeStylingUnit( color="bg-white text-red-200 ring-red-100 focus-visible:outline-red-600", css=_secondary_btn_styling, ), ), alert=ThemeStylingVariant( primary=ThemeStylingUnit(color="bg-amber-500 text-white hover:bg-amber-400 focus-visible:outline-amber-500"), primary_disabled=ThemeStylingUnit(color="bg-amber-100 text-orange-300 focus-visible:outline-amber-500"), secondary=ThemeStylingUnit( color="bg-white text-amber-500 ring-amber-300 hover:bg-amber-100 focus-visible:outline-amber-500", css=_secondary_btn_styling, ), secondary_disabled=ThemeStylingUnit( color="bg-white text-orange-200 ring-amber-100 focus-visible:outline-amber-500", css=_secondary_btn_styling, ), ), success=ThemeStylingVariant( primary=ThemeStylingUnit(color="bg-green-600 text-white hover:bg-green-500 focus-visible:outline-green-600"), primary_disabled=ThemeStylingUnit(color="bg-green-300 text-white focus-visible:outline-green-600"), secondary=ThemeStylingUnit( color="bg-white text-green-600 ring-green-300 hover:bg-green-100 focus-visible:outline-green-600", css=_secondary_btn_styling, ), secondary_disabled=ThemeStylingUnit( color="bg-white text-green-200 ring-green-100 focus-visible:outline-green-600", css=_secondary_btn_styling, ), ), info=ThemeStylingVariant( primary=ThemeStylingUnit(color="bg-sky-600 text-white hover:bg-sky-500 focus-visible:outline-sky-600"), primary_disabled=ThemeStylingUnit(color="bg-sky-300 text-white focus-visible:outline-sky-600"), secondary=ThemeStylingUnit( color="bg-white text-sky-600 ring-sky-300 hover:bg-sky-100 focus-visible:outline-sky-600", css=_secondary_btn_styling, ), secondary_disabled=ThemeStylingUnit( color="bg-white text-sky-200 ring-sky-100 focus-visible:outline-sky-600", css=_secondary_btn_styling, ), ), sidebar="bg-neutral-900 text-neutral-200", sidebar_link="hover:bg-neutral-700 hover:text-white transition", background="bg-neutral-200", tab_active="border-blue-700", tab_text_active="text-blue-700", tab_text_inactive="text-gray-500 hover:text-blue-700", check_interactive="bg-blue-600 group-hover:bg-blue-500 transition", check_static="bg-blue-600", check_outline="border-2 border-blue-600 bg-white", ) def get_styling_css( variant: Optional["ThemeVariant"] = None, color: Optional["ThemeColor"] = None, disabled: Optional[bool] = None, ): """ Dynamically access CSS styling classes for a specific variant and state. E.g. following two calls get styling classes for: 1. Secondary error state 1. Secondary alert disabled state 2. Primary default disabled state ```py get_styling_css('secondary', 'error') get_styling_css('secondary', 'alert', disabled=True) get_styling_css(disabled=True) ``` """ variant = variant or "primary" color = color or "default" disabled = disabled if disabled is not None else False color_variants: ThemeStylingVariant = getattr(theme, color) if variant not in VARIANTS: raise ValueError(f'Unknown theme variant "{variant}", must be one of {VARIANTS}') variant_name = variant if not disabled else f"{variant}_disabled" styling: ThemeStylingUnit = getattr(color_variants, variant_name) css = f"{styling.color} {styling.css}".strip() return css ##################################### # HELPERS ##################################### T = TypeVar("T") U = TypeVar("U") def format_timestamp(timestamp: datetime): """ If the timestamp is more than 7 days ago, format it as "Jan 1, 2025". Otherwise, format it as a natural time string (e.g. "3 days ago"). """ if now() - timestamp > timedelta(days=7): return timestamp.strftime("%b %-d, %Y") else: return naturaltime(timestamp) def group_by( lst: Iterable[T], keyfn: Callable[[T, int], Any], mapper: Optional[Callable[[T, int], U]] = None, ): """ Given a list, generates a key for each item in the list using the `keyfn`. Returns a dictionary of generated keys, where each value is a list of corresponding items. Similar to Lodash's `groupby`. Optionally map the values in the lists with `mapper`. """ grouped: Dict[Any, List[Union[U, T]]] = {} for index, item in enumerate(lst): key = dynamic_apply(keyfn, item, index) if key not in grouped: grouped[key] = [] mapped_item = dynamic_apply(mapper, item, index) if mapper else item grouped[key].append(mapped_item) return grouped def dynamic_apply(fn: Callable, *args): """ Given a function and positional arguments that should be applied to given function, this helper will apply only as many arguments as the function defines, or only as much as the number of arguments that we can apply. """ mapper_args_count = len(signature(fn).parameters) num_args_to_apply = min(mapper_args_count, len(args)) first_n_args = args[:num_args_to_apply] return fn(*first_n_args) ##################################### # SHARED FORMS ##################################### class ConditionalEditForm(forms.Form): """ Subclass of Django's Form that sets all fields as NON-editable based on the `editable` field. """ editable: bool = True def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if self.editable is not None and not self.editable: self._disable_all_form_fields() def _disable_all_form_fields(self): fields: Dict[str, forms.Field] = self.fields # type: ignore[assignment] for form_field in fields.values(): form_field.widget.attrs["readonly"] = True ##################################### # TEMPLATE TAG FILTERS ##################################### @default_library.filter("alpine") def to_alpine_json(value: dict): """ Serialize Python object such that it can be passed to Alpine callbacks in Django templates. """ # Avoid using double quotes since this value is passed to an HTML element # attribute. # NOTE: Maybe we could use HTML escaping to avoid the issue with double quotes? data = json.dumps(value).replace('"', "'") return data @default_library.filter("json") def to_json(value: dict): """Serialize Python object to JSON.""" data = json.dumps(value) return data @default_library.simple_tag def define(val=None): return val @default_library.filter def get_item(dictionary: dict, key: str): return dictionary.get(key) @default_library.filter("js") def serialize_to_js(obj): """ Serialize a Python object to a JS-like expression. Works recursively with nested dictionaries and lists. So given a dict `{"a": 123, "b": "console.log('abc')", "c": "'mystring'"}` The filter exports: `"{ a: 123, b: console.log('abc'), c: 'mystring' }"` """ if isinstance(obj, dict): # If the object is a dictionary, iterate through key-value pairs items = [] for key, value in obj.items(): serialized_value = serialize_to_js(value) # Recursively serialize the value items.append(f"{key}: {serialized_value}") return f"{{ {', '.join(items)} }}" elif isinstance(obj, (list, tuple)): # If the object is a list, recursively serialize each item serialized_items = [serialize_to_js(item) for item in obj] return f"[{', '.join(serialized_items)}]" elif isinstance(obj, str): return obj else: # For other types (int, float, etc.), just return the string representation return str(obj) ##################################### # BUTTON ##################################### button_template_str: types.django_html = """ {# Based on buttons from https://tailwindui.com/components/application-ui/overlays/modals #} {% if is_link %} {% else %} {% endif %} """ class ButtonData(NamedTuple): href: Optional[str] = None link: Optional[bool] = None disabled: Optional[bool] = False variant: Union["ThemeVariant", Literal["plain"]] = "primary" color: Union["ThemeColor", str] = "default" type: Optional[str] = "button" attrs: Optional[dict] = None slot_content: Optional[str] = "" @registry.library.simple_tag(takes_context=True) def button(context: Context, data: ButtonData): common_css = ( "inline-flex w-full text-sm font-semibold" " sm:mt-0 sm:w-auto focus-visible:outline-2 focus-visible:outline-offset-2" ) if data.variant == "plain": all_css_class = common_css else: button_classes = get_styling_css(data.variant, data.color, data.disabled) # type: ignore[arg-type] all_css_class = f"{button_classes} {common_css} px-3 py-2 justify-center rounded-md shadow-sm" is_link = not data.disabled and (data.href or data.link) all_attrs = {**(data.attrs or {})} if data.disabled: all_attrs["aria-disabled"] = "true" with context.push( { "href": data.href, "disabled": data.disabled, "type": data.type, "btn_class": all_css_class, "attrs": all_attrs, "is_link": is_link, "slot_content": data.slot_content, } ): return lazy_load_template(button_template_str).render(context) ##################################### # MENU ##################################### MaybeNestedList = List[Union[T, List[T]]] MenuItemGroup = List["MenuItem"] @dataclass(frozen=True) class MenuItem: """ Single menu item used with the `menu` components. Menu items can be divided by a horizontal line to indicate that the items belong together. In code, we specify this by wrapping the item(s) as an array. ```py menu_items = [ # Group 1 [ MenuItem(value="Edit", link="#"), MenuItem(value="Duplicate"), ], # Group 2 MenuItem(value="Add step before"), MenuItem(value="Add step after"), MenuItem(value="Add child step"), # Group 3 [ MenuItem(value="Delete"), ], ] ``` """ value: Any """Value of the menu item to render.""" link: Optional[str] = None """ If set, the menu item will be wrapped in an `` tag pointing to this link. """ item_attrs: Optional[dict] = None """HTML attributes specific to this menu item.""" menu_template_str: types.django_html = """ {# Based on https://tailwindui.com/components/application-ui/elements/dropdowns #} {% comment %} NOTE: {{ model }} is the Alpine variable used for opening/closing. The variable name is set dynamically, hence we use Django's double curly braces to refer to it. {% endcomment %}
{# This is what opens the modal #} {% if slot_activator %}
{{ slot_activator }}
{% endif %} {% menu_list menu_list_data %}
""" class MenuData(NamedTuple): items: MaybeNestedList[Union[MenuItem, str]] model: Optional[str] = None # CSS and HTML attributes attrs: Optional[dict] = None activator_attrs: Optional[dict] = None list_attrs: Optional[dict] = None # UX close_on_esc: Optional[bool] = True close_on_click_outside: Optional[bool] = True anchor: Optional[str] = None anchor_dir: Optional[str] = "bottom" # Slots slot_activator: Optional[str] = None @registry.library.simple_tag(takes_context=True) def menu(context: Context, data: MenuData): is_model_overriden = bool(data.model) model = data.model or "open" all_list_attrs: dict = {} if data.list_attrs: all_list_attrs.update(data.list_attrs) if data.anchor: all_list_attrs[f"x-anchor.{data.anchor_dir}"] = data.anchor all_list_attrs.update( { "x-show": model, "x-cloak": "", } ) menu_list_data = MenuListData( items=data.items, attrs=all_list_attrs, ) with context.push( { "model": model, "is_model_overriden": is_model_overriden, "close_on_click_outside": data.close_on_click_outside, "close_on_esc": data.close_on_esc, "activator_attrs": data.activator_attrs, "attrs": data.attrs, "menu_list_data": menu_list_data, "slot_activator": data.slot_activator, } ): return lazy_load_template(menu_template_str).render(context) ##################################### # MENU LIST ##################################### def _normalize_item(item: Union[MenuItem, str]): # Wrap plain value in MenuItem if not isinstance(item, MenuItem): return MenuItem(value=item) return item # Normalize a list of MenuItems such that they are all in groups. We achieve # this by collecting consecutive ungrouped items into a single group. def _normalize_items_to_groups(items: MaybeNestedList[Union[MenuItem, str]]): def is_group(item): return isinstance(item, Iterable) and not isinstance(item, str) groups: List[List[Union[MenuItem, str]]] = [] curr_group: Optional[List[Union[MenuItem, str]]] = None for index, item_or_grp in enumerate(items): group: List[Union[MenuItem, str]] = [] if isinstance(item_or_grp, Iterable) and not isinstance(item_or_grp, str): group = item_or_grp else: if curr_group is not None: group = curr_group else: group = curr_group = [] group.append(item_or_grp) is_not_last = index < len(items) - 1 if is_not_last and not is_group(items[index + 1]): continue groups.append(group) curr_group = None return groups def prepare_menu_items(items: MaybeNestedList[Union[MenuItem, str]]): groups = _normalize_items_to_groups(items) normalized_groups: List[MenuItemGroup] = [] for group in groups: norm_group = list(map(_normalize_item, group)) normalized_groups.append(norm_group) return normalized_groups menu_list_template_str: types.django_html = """ {# Based on https://tailwindui.com/components/application-ui/elements/dropdowns #}
""" # noqa: E501 class MenuListData(NamedTuple): items: MaybeNestedList[Union[MenuItem, str]] attrs: Optional[dict] = None @registry.library.simple_tag(takes_context=True) def menu_list(context: Context, data: MenuListData): item_groups = prepare_menu_items(data.items) with context.push( { "item_groups": item_groups, "attrs": data.attrs, } ): return lazy_load_template(menu_list_template_str).render(context) ##################################### # TABLE ##################################### class TableHeader(NamedTuple): """Table header data structure used with the `table` components.""" name: str """Header name, as displayed to the users.""" key: str """Dictionary key on `TableRow.cols` that holds data for this header.""" hidden: Optional[bool] = None """ Whether to hide the header. The column will still be rendered, only the column title will be hidden. """ cell_attrs: Optional[dict] = None """HTML attributes specific to this table header cell.""" @dataclass(frozen=True) class TableCell: """Single table cell (row + col) used with the `table` components.""" value: Any """Value of the cell to render.""" colspan: int = 1 """ How many columns should this cell occupy. See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/td#colspan """ link: Optional[str] = None """ If set, the cell value will be wrapped in an `` tag pointing to this link. """ link_attrs: Optional[dict] = None """ HTML attributes for the `` tag wrapping the link, if `link` is set. """ cell_attrs: Optional[dict] = None """HTML attributes specific to this table cell.""" linebreaks: Optional[bool] = None """Whether to apply the `linebreaks` filter to this table cell.""" def __post_init__(self): if not isinstance(self.colspan, int) or self.colspan < 1: raise ValueError("TableCell.colspan must be a non-negative integer." f" Instead got {self.colspan}") NULL_CELL = TableCell("") """Definition of an empty cell""" @dataclass(frozen=True) class TableRow: """ Table row data structure used with the `table` components. TableRow holds columnar data in the `cols` dict, e.g.: ```py rows = [ TableRow( cols={ 'name': TableCell( value='My Name', link='https://www.example.com', link_attrs={ "class": 'font-weight-bold', }, ), 'desc': 'Lorem Ipsum' } ), ] ``` """ cols: Dict[str, TableCell] = field(default_factory=dict) """Data within this row.""" row_attrs: Optional[dict] = None """HTML attributes for this row.""" col_attrs: Optional[dict] = None """ HTML attributes for each column in this row. NOTE: This may be overriden by `TableCell.cell_attrs`. """ def create_table_row( cols: Optional[Dict[str, Union[TableCell, Any]]] = None, row_attrs: Optional[dict] = None, col_attrs: Optional[dict] = None, ): # Normalize the values of `cols` to `TableCell` instances. This # way we allow to set values of `self.cols` dict as plain values, e.g.: # # ```py # create_table_row( # cols={ # "my_value": 12 # } # ) # ``` # # Instead of having to wrap it in `TableCell` instance, like so: # # ```py # TableRow( # cols={ # "my_value": TableCell(value=12) # } # ) # ``` resolved_cols: Dict[str, TableCell] = {} if cols: for key, val in cols.items(): resolved_cols[key] = TableCell(value=val) if not isinstance(val, TableCell) else val return TableRow( cols=resolved_cols, row_attrs=row_attrs, col_attrs=col_attrs, ) def prepare_row_headers(row: TableRow, headers: List[TableHeader]): # Skip headers when cells have colspan > 1, thus merging those cells final_row_headers = [] headers_to_skip = 0 for header in headers: if headers_to_skip > 0: headers_to_skip -= 1 continue final_row_headers.append(header) cell = row.cols.get(header.key, None) if cell is not None: headers_to_skip = cell.colspan - 1 return final_row_headers table_template_str: types.django_html = """
{% for header in headers %} {% endfor %} {% for row, row_headers in rows_to_render %} {% for header in row_headers %} {% define row.cols|get_item:header.key|default_if_none:NULL_CELL as cell %} {% endfor %} {% endfor %}
{% if header.hidden %} {{ header.name }} {% else %} {{ header.name }} {% endif %}
{% if cell.link %} {% if cell.linebreaks %} {{ cell.value | linebreaksbr }} {% else %} {{ cell.value }} {% endif %} {% else %} {% if cell.linebreaks %} {{ cell.value | linebreaksbr }} {% else %} {{ cell.value }} {% endif %} {% endif %}
""" class TableData(NamedTuple): headers: List[TableHeader] rows: List[TableRow] attrs: Optional[dict] = None @registry.library.simple_tag(takes_context=True) def table(context: Context, data: TableData): rows_to_render = [tuple([row, prepare_row_headers(row, data.headers)]) for row in data.rows] with context.push( { "headers": data.headers, "rows_to_render": rows_to_render, "NULL_CELL": NULL_CELL, "attrs": data.attrs, } ): return lazy_load_template(table_template_str).render(context) ##################################### # ICON ##################################### icon_template_str: types.django_html = """
{% if href %} {% else %} {% endif %} {% heroicon heroicon_data %} {{ slot_content }} {% if href %} {% else %} {% endif %}
""" class IconData(NamedTuple): name: str variant: Optional[str] = None size: Optional[int] = None stroke_width: Optional[float] = None viewbox: Optional[str] = None svg_attrs: Optional[dict] = None # Note: Unlike the underlying icon component, this component uses color CSS classes color: Optional[str] = "" icon_color: Optional[str] = "" text_color: Optional[str] = "" href: Optional[str] = None text_attrs: Optional[dict] = None link_attrs: Optional[dict] = None attrs: Optional[dict] = None # Slots slot_content: Optional[str] = None @registry.library.simple_tag(takes_context=True) def icon(context: Context, data: IconData): # Allow to set icon and text independently, or both at same time via `color` prop icon_color = data.color if not data.icon_color else data.icon_color text_color = data.color if not data.text_color else data.text_color svg_attrs = data.svg_attrs.copy() if data.svg_attrs else {} if not svg_attrs.get("class"): svg_attrs["class"] = "" svg_attrs["class"] += f" {icon_color or ''} h-6 w-6 shrink-0" heroicon_data = HeroIconData( name=data.name, variant=data.variant, size=data.size, viewbox=data.viewbox, stroke_width=data.stroke_width, attrs=svg_attrs, ) with context.push( { "name": data.name, "variant": data.variant, "size": data.size, "viewbox": data.viewbox, "stroke_width": data.stroke_width, "svg_attrs": svg_attrs, "text_color": text_color, "text_attrs": data.text_attrs, "link_attrs": data.link_attrs, "href": data.href, "attrs": data.attrs, "heroicon_data": heroicon_data, "slot_content": data.slot_content, } ): return lazy_load_template(icon_template_str).render(context) ##################################### # HEROICONS ##################################### # Single hard-coded icon ICONS = { "outline": { "academic-cap": [ { "stroke-linecap": "round", "stroke-linejoin": "round", "d": "M4.26 10.147a60.438 60.438 0 0 0-.491 6.347A48.62 48.62 0 0 1 12 20.904a48.62 48.62 0 0 1 8.232-4.41 60.46 60.46 0 0 0-.491-6.347m-15.482 0a50.636 50.636 0 0 0-2.658-.813A59.906 59.906 0 0 1 12 3.493a59.903 59.903 0 0 1 10.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.717 50.717 0 0 1 12 13.489a50.702 50.702 0 0 1 7.74-3.342M6.75 15a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm0 0v-3.675A55.378 55.378 0 0 1 12 8.443m-7.007 11.55A5.981 5.981 0 0 0 6.75 15.75v-1.5", # noqa: E501 } ] } } class ComponentDefaultsMeta(type): def __new__(mcs, name: str, bases: Tuple, namespace: Dict) -> Type: # Apply dataclass decorator to the class return dataclass(super().__new__(mcs, name, bases, namespace)) class ComponentDefaults(metaclass=ComponentDefaultsMeta): def __post_init__(self) -> None: fields = self.__class__.__dataclass_fields__ # type: ignore[attr-defined] for field_name, dataclass_field in fields.items(): if dataclass_field.default is not MISSING: if getattr(self, field_name) is None: setattr(self, field_name, dataclass_field.default) class IconDefaults(ComponentDefaults): name: str variant: str = "outline" size: int = 24 color: str = "currentColor" stroke_width: float = 1.5 viewbox: str = "0 0 24 24" attrs: Optional[Dict] = None heroicon_template_str: types.django_html = """ {% load component_tags %} {% for path_attrs in icon_paths %} {% endfor %} """ class HeroIconData(NamedTuple): name: str variant: Optional[str] = None size: Optional[int] = None color: Optional[str] = None stroke_width: Optional[float] = None viewbox: Optional[str] = None attrs: Optional[Dict] = None @registry.library.simple_tag(takes_context=True) def heroicon(context: Context, data: HeroIconData): kwargs = IconDefaults(**data._asdict()) if kwargs.variant not in ["outline", "solid"]: raise ValueError(f"Invalid variant: {kwargs.variant}. Must be either 'outline' or 'solid'") # variant_icons = ICONS[kwargs.variant] variant_icons = ICONS["outline"] icon_name = "academic-cap" if icon_name not in variant_icons: # Give users a helpful message by fuzzy-search the closest key msg = "" icon_names = list(variant_icons.keys()) if icon_names: fuzzy_matches = difflib.get_close_matches(icon_name, icon_names, n=3, cutoff=0.7) if fuzzy_matches: suggestions = ", ".join([f"'{match}'" for match in fuzzy_matches]) msg += f". Did you mean any of {suggestions}?" raise ValueError(f"Invalid icon name: {icon_name}{msg}") icon_paths = variant_icons[icon_name] # These are set as "default" attributes, so users can override them # by passing them in the `attrs` argument. default_attrs: Dict[str, Any] = { "viewBox": kwargs.viewbox, "style": f"width: {kwargs.size}px; height: {kwargs.size}px", "aria-hidden": "true", } # The SVG applies the color differently in "outline" and "solid" versions if kwargs.variant == "outline": default_attrs["fill"] = "none" default_attrs["stroke"] = kwargs.color default_attrs["stroke-width"] = kwargs.stroke_width else: default_attrs["fill"] = kwargs.color default_attrs["stroke"] = "none" with context.push( { "icon_paths": icon_paths, "default_attrs": default_attrs, "attrs": kwargs.attrs, } ): return lazy_load_template(heroicon_template_str).render(context) ##################################### # EXPANSION PANEL ##################################### expansion_panel_template_str: types.django_html = """
{% if icon_position == "left" %} {% icon expand_icon_data %} {% endif %} {{ slot_header }} {% if icon_position == "right" %} {% icon expand_icon_data %} {% endif %}
{{ slot_content }}
""" class ExpansionPanelData(NamedTuple): open: Optional[bool] = False panel_id: Optional[str] = None attrs: Optional[dict] = None header_attrs: Optional[dict] = None content_attrs: Optional[dict] = None icon_position: Literal["left", "right"] = "left" slot_header: Optional[str] = None slot_content: Optional[str] = None @registry.library.simple_tag(takes_context=True) def expansion_panel(context: Context, data: ExpansionPanelData): init_data = {"open": data.open} expand_icon_data = IconData( name="chevron-down", variant="outline", attrs={ "style": "width: fit-content;", "class": "{ 'rotate-180': isOpen }", }, ) with context.push( { "attrs": data.attrs, "header_attrs": data.header_attrs, "content_attrs": data.content_attrs, "icon_position": data.icon_position, "init_data": init_data, "panel_id": data.panel_id if data.panel_id else False, "expand_icon_data": expand_icon_data, "slot_header": data.slot_header, "slot_content": data.slot_content, } ): return lazy_load_template(expansion_panel_template_str).render(context) ##################################### # PROJECT_PAGE ##################################### # Tabs on this page and the query params to open specific tabs on page load. class ProjectPageTabsToQueryParams(Enum): PROJECT_INFO = {"tabs-proj-right": "1"} OUTPUTS = {"tabs-proj-right": "5"} project_page_header_template_str: types.django_html = """

{{ project.name }}

{{ project.start_date }} - {{ project.end_date }}
""" project_page_tabs_template_str: types.django_html = """ {% for tab in project_page_tabs %} {% tab_item tab %} {% endfor %} """ class ProjectPageData(NamedTuple): phases: List[ProjectPhase] project_tags: List[str] notes_1: List[ProjectNote] comments_by_notes_1: Dict[str, List[ProjectNoteComment]] notes_2: List[ProjectNote] comments_by_notes_2: Dict[str, List[ProjectNoteComment]] notes_3: List[ProjectNote] comments_by_notes_3: Dict[str, List[ProjectNoteComment]] status_updates: List[ProjectStatusUpdate] roles_with_users: List[ProjectRole] contacts: List[ProjectContact] outputs: List["OutputWithAttachmentsAndDeps"] user_is_project_member: bool user_is_project_owner: bool phase_titles: Dict[ProjectPhaseType, str] # Used by project layout layout_data: "ProjectLayoutData" project: Project breadcrumbs: Optional[List["Breadcrumb"]] = None @registry.library.simple_tag(takes_context=True) def project_page(context: Context, data: ProjectPageData): rendered_phases: List[ListItem] = [] phases_by_type = {p["phase_template"]["type"]: p for p in data.phases} for phase_meta in PROJECT_PHASES_META.values(): phase = phases_by_type[phase_meta.type] title = data.phase_titles[phase_meta.type] rendered_phases.append( ListItem( value=title, link=f"/projects/{data.project['id']}/phases/{phase['phase_template']['type']}", ) ) project_page_tabs = [ TabItemData( header="Project Info", slot_content=project_info( context, ProjectInfoData( project=data.project, project_tags=data.project_tags, roles_with_users=data.roles_with_users, contacts=data.contacts, status_updates=data.status_updates, editable=data.user_is_project_owner, ) ), ), TabItemData( header="Notes 1", slot_content=project_notes( context, ProjectNotesData( project_id=data.project["id"], notes=data.notes_1, comments_by_notes=data.comments_by_notes_1, # type: ignore[arg-type] editable=data.user_is_project_member, ) ), ), TabItemData( header="Notes 2", slot_content=project_notes( context, ProjectNotesData( project_id=data.project["id"], notes=data.notes_2, comments_by_notes=data.comments_by_notes_2, # type: ignore[arg-type] editable=data.user_is_project_member, ) ), ), TabItemData( header="Notes 3", slot_content=project_notes( context, ProjectNotesData( project_id=data.project["id"], notes=data.notes_3, comments_by_notes=data.comments_by_notes_3, # type: ignore[arg-type] editable=data.user_is_project_member, ) ), ), TabItemData( header="Outputs", slot_content=project_outputs_summary( context, ProjectOutputsSummaryData( project_id=data.project["id"], outputs=data.outputs, editable=data.user_is_project_member, phase_titles=data.phase_titles, ) ), ), ] def render_tabs(tabs_context: Context): with tabs_context.push( { "project_page_tabs": project_page_tabs, } ): return lazy_load_template(project_page_tabs_template_str).render(tabs_context) phases_content = list_tag( context, ListData( items=rendered_phases, item_attrs={"class": "py-5"}, ) ) with context.push( { "project": data.project, } ): header_content = lazy_load_template(project_page_header_template_str).render(context) layout_tabbed_data = ProjectLayoutTabbedData( layout_data=data.layout_data, breadcrumbs=data.breadcrumbs, top_level_tab_index=1, slot_left_panel=phases_content, slot_header=header_content, slot_tabs=CallableSlot(render_tabs), ) return project_layout_tabbed(context, layout_tabbed_data) ##################################### # PROJECT_LAYOUT_TABBED ##################################### class ProjectLayoutData(NamedTuple): request: HttpRequest active_projects: List[Project] project: Project bookmarks: List[ProjectBookmark] def gen_tabs(project_id: int): return [ TabStaticEntry( header="Tab 2", href=f"/projects/{project_id}/tab-2", content=None, ), TabStaticEntry( header="Tab 1", href=f"/projects/{project_id}/tab-1", content=None, ), ] project_layout_tabbed_content_template_str: types.django_html = """ {{ slot_header }} {% if top_level_tab_index is not None %} {% tabs_static content_tabs_static_data %} {% endif %}
{# Split the content to 2 columns, based on whether `left_panel` slot is filled #} {% if slot_left_panel %}
{{ slot_left_panel }}
{% endif %}
{% tabs content_tabs_data %}
{% if slot_left_panel %}
{% endif %}
""" class ProjectLayoutTabbedData(NamedTuple): layout_data: ProjectLayoutData breadcrumbs: Optional[List["Breadcrumb"]] = None top_level_tab_index: Optional[int] = None variant: Literal["thirds", "halves"] = "thirds" # Slots slot_left_panel: Optional[str] = None slot_js: Optional[str] = None slot_css: Optional[str] = None slot_header: Optional[str] = None slot_tabs: Optional["CallableSlot"] = None @registry.library.simple_tag(takes_context=True) def project_layout_tabbed(context: Context, data: ProjectLayoutTabbedData): projects_url = "/projects" curr_project_url = f"/projects/{data.layout_data.project['id']}" prefixed_breadcrumbs = [ Breadcrumb( link=projects_url, value=icon( context, IconData( name="home", variant="outline", size=20, stroke_width=2, color="text-gray-400 hover:text-gray-500", ), ), ), Breadcrumb(value=data.layout_data.project["name"], link=curr_project_url), *(data.breadcrumbs or []), ] top_level_tabs = gen_tabs(data.layout_data.project["id"]) left_pannel_attrs = { "class": "w-1/3" if data.variant == "thirds" else "w-1/2", } right_pannel_attrs = { "class": "w-2/3" if data.variant == "thirds" else "w-1/2", } breadcrumbs_content = breadcrumbs_tag(context, BreadcrumbsData(items=prefixed_breadcrumbs)) bookmarks_content = bookmarks_tag( context, BookmarksData(bookmarks=data.layout_data.bookmarks, project_id=data.layout_data.project["id"]) ) content_tabs_static_data = TabsStaticData( tabs=top_level_tabs, tab_index=data.top_level_tab_index or 0, ) content_tabs_data = TabsData( name="proj-right", attrs={"class": "p-6 h-full"}, content_attrs={"class": "flex flex-col"}, slot_content=data.slot_tabs, ) with context.push( { "layout_data": data.layout_data, "breadcrumbs": prefixed_breadcrumbs, "bookmarks": data.layout_data.bookmarks, "project": data.layout_data.project, "top_level_tabs": top_level_tabs, "top_level_tab_index": data.top_level_tab_index, "theme": theme, "left_pannel_attrs": left_pannel_attrs, "right_pannel_attrs": right_pannel_attrs, "content_tabs_static_data": content_tabs_static_data, "content_tabs_data": content_tabs_data, "slot_left_panel": data.slot_left_panel, "slot_header": data.slot_header, "slot_tabs": data.slot_tabs, } ): layout_content = lazy_load_template(project_layout_tabbed_content_template_str).render(context) layout_data = LayoutData( data=data.layout_data, attrs=None, slot_js=data.slot_js, slot_css=data.slot_css, slot_header=breadcrumbs_content, slot_sidebar=bookmarks_content, slot_content=layout_content, ) return layout(context, layout_data) ##################################### # LAYOUT ##################################### # inputs: # - attrs # - sidebar_data # - navbar_data # - slot_header # - slot_content layout_base_content_template_str: types.django_html = """
{% navbar navbar_data %}
{{ slot_header }}
{{ slot_content }}
""" layout_template_str: types.django_html = """ {% render_context_provider render_context_provider_data %} """ class LayoutData(NamedTuple): data: ProjectLayoutData attrs: Optional[dict] = None # Slots slot_js: Optional[str] = None slot_css: Optional[str] = None slot_header: Optional[str] = None slot_content: Optional[str] = None slot_sidebar: Optional[str] = None @registry.library.simple_tag(takes_context=True) def layout(context: Context, data: LayoutData): def render_context_provider_slot(provided_context: Context): navbar_data = NavbarData( attrs={ "@sidebar_toggle": "toggleSidebar", }, ) sidebar_data = SidebarData( active_projects=data.data.active_projects, slot_content=data.slot_sidebar, ) with provided_context.push( { "attrs": data.attrs, "navbar_data": navbar_data, "sidebar_data": sidebar_data, "slot_header": data.slot_header, "slot_content": data.slot_content, } ): layout_base_content = lazy_load_template(layout_base_content_template_str).render(provided_context) base_data = BaseData( slot_content=layout_base_content, slot_css=data.slot_css, slot_js=(data.slot_js or "") + """ """, ) with provided_context.push( { "base_data": base_data, } ): return lazy_load_template("{% base base_data %}").render(provided_context) render_context_provider_data = RenderContextProviderData( request=data.data.request, slot_content=CallableSlot(render=render_context_provider_slot), ) with context.push( { "render_context_provider_data": render_context_provider_data, } ): return lazy_load_template(layout_template_str).render(context) ##################################### # RENDER_CONTEXT_PROVIDER ##################################### class RenderContext(NamedTuple): """ Data that's commonly available in all template rendering. In templates, we can assume that the data defined here is ALWAYS defined. """ request: HttpRequest user: User csrf_token: str class CallableSlot(NamedTuple): render: Callable[[Context], str] class RenderContextProviderData(NamedTuple): request: HttpRequest slot_content: Optional[CallableSlot] = None # This component "provides" data. This is similar to ContextProviders # in React, or the "provide" part of Vue's provide/inject feature. # # Components nested inside `RenderContextProvider` can access the # data with `self.inject("render_context")`. @registry.library.simple_tag(takes_context=True) def render_context_provider(context: Context, data: RenderContextProviderData) -> str: if not data.slot_content: return "" csrf_token = csrf.get_token(data.request) render_context = RenderContext( request=data.request, user=data.request.user, csrf_token=csrf_token, ) with context.push({"render_context": render_context}): return data.slot_content.render(context) ##################################### # BASE ##################################### base_template_str: types.django_html = """ {% load static %} DEMO {{ slot_css }} {{ slot_content }} {# AlpineJS + Plugins #} {# HTMX #} {# Axios (AJAX) #} {# Any extra scripts #} {{ slot_js }} """ class BaseData(NamedTuple): slot_css: Optional[str] = None slot_js: Optional[str] = None slot_content: Optional[str] = None @registry.library.simple_tag(takes_context=True) def base(context: Context, data: BaseData) -> str: render_context: RenderContext = context["render_context"] with context.push( { "csrf_token": render_context.csrf_token, "theme": theme, # Slots "slot_css": data.slot_css, "slot_js": data.slot_js, "slot_content": data.slot_content, } ): return lazy_load_template(base_template_str).render(context) ##################################### # SIDEBAR ##################################### class SidebarItem(NamedTuple): name: str icon: Optional[str] = None icon_variant: Optional[str] = None href: Optional[str] = None children: Optional[List["SidebarItem"]] = None # Links in the sidebar. def gen_sidebar_menu_items(active_projects: List[Project]) -> List[Tuple[IconData, List[ButtonData]]]: items: List[Tuple[IconData, List[ButtonData]]] = [ ( IconData( name="home", variant="outline", href="/", color=theme.sidebar_link, text_attrs={ "class": "p-2", }, slot_content="Homepage", ), [], ), ( IconData( name="folder", variant="outline", href="/projects", color=theme.sidebar_link, text_attrs={ "class": "p-2", }, slot_content="Projects", ), [ ButtonData( variant="plain", href=f"/projects/{project['id']}", slot_content=project["name"], attrs={ "class": "p-2 !w-full", }, ) for project in active_projects ], ), ( IconData( name="folder", variant="outline", href="/page-3", color=theme.sidebar_link, text_attrs={ "class": "p-2", }, slot_content="Page 3", ), [], ), ( IconData( name="bars-arrow-down", variant="outline", href="/page-4", color=theme.sidebar_link, text_attrs={ "class": "p-2", }, slot_content="Page 4", ), [], ), ( IconData( name="forward", variant="outline", href="/page-5", color=theme.sidebar_link, text_attrs={ "class": "p-2", }, slot_content="Page 5", ), [], ), ( IconData( name="archive-box", variant="outline", href="/faq", color=theme.sidebar_link, text_attrs={ "class": "p-2", }, slot_content="FAQ", ), [], ), ] return items sidebar_template_str: types.django_html = """
DEMO
""" class SidebarData(NamedTuple): active_projects: List[Project] attrs: Optional[dict] = None slot_content: Optional[str] = None @registry.library.simple_tag(takes_context=True) def sidebar(context: Context, data: SidebarData): render_context: RenderContext = context["render_context"] user = render_context.user items = gen_sidebar_menu_items(data.active_projects) faq_url = "/faq" download_icon_data = IconData( name="document-arrow-down", variant="outline", color=theme.sidebar_link, text_attrs={ "class": "p-2", }, slot_content="Download", ) feedback_icon_data = IconData( name="megaphone", variant="outline", color=theme.sidebar_link, text_attrs={ "class": "p-2", }, link_attrs={ "target": "_blank", }, slot_content="Feedback", ) faq_icon_data = IconData( name="user-group", variant="outline", href=faq_url, color=theme.sidebar_link, text_attrs={ "class": "p-2", }, slot_content="FAQ", ) with context.push( { "items": items, "attrs": data.attrs, "user": user, "theme": theme, "faq_url": faq_url, "download_icon_data": download_icon_data, "feedback_icon_data": feedback_icon_data, "faq_icon_data": faq_icon_data, # Slots "slot_content": data.slot_content, } ): return lazy_load_template(sidebar_template_str).render(context) ##################################### # NAVBAR ##################################### navbar_template_str: types.django_html = """
{# Search not implemented #}
""" # noqa: E501 class NavbarData(NamedTuple): attrs: Optional[dict] = None @registry.library.simple_tag(takes_context=True) def navbar(context: Context, data: NavbarData): sidebar_toggle_icon_data = IconData( name="bars-3", variant="outline", ) with context.push( { "sidebar_toggle_icon_data": sidebar_toggle_icon_data, "attrs": data.attrs, } ): return lazy_load_template(navbar_template_str).render(context) ##################################### # DIALOG ##################################### def construct_btn_onclick(model: str, btn_on_click: Optional[str]): """ We want to allow the component users to define Alpine.js `@click` actions. However, we also need to use `@click` to close the dialog after clicking one of the buttons. Hence, this function constructs the '@click' attribute, such that we can do both. NOTE: `model` is the name of the Alpine variable used by the dialog. """ on_click_cb = f"{model} = false;" if btn_on_click: on_click_cb = f"{btn_on_click}; {on_click_cb}" return mark_safe(on_click_cb) dialog_template_str: types.django_html = """ {# Based on https://tailwindui.com/components/application-ui/overlays/modals #} {% comment %} NOTE: {{ model }} is the Alpine variable used for opening/closing. The variable name is set dynamically, hence we use Django's double curly braces to refer to it. {% endcomment %}
{% if slot_activator %} {# This is what opens the modal #}
{{ slot_activator }}
{% endif %}
""" # noqa: E501 class DialogData(NamedTuple): model: Optional[str] = None # Classes and HTML attributes attrs: Optional[dict] = None activator_attrs: Optional[dict] = None title_attrs: Optional[dict] = None content_attrs: Optional[dict] = None # Confirm button confirm_hide: Optional[bool] = None confirm_text: Optional[str] = "Confirm" confirm_href: Optional[str] = None confirm_disabled: Optional[bool] = None confirm_variant: Optional["ThemeVariant"] = "primary" confirm_color: Optional["ThemeColor"] = None confirm_type: Optional[str] = None confirm_on_click: Optional[str] = "" confirm_attrs: Optional[dict] = None # Cancel button cancel_hide: Optional[bool] = None cancel_text: Optional[str] = "Cancel" cancel_href: Optional[str] = None cancel_disabled: Optional[bool] = None cancel_variant: Optional["ThemeVariant"] = "secondary" cancel_color: Optional["ThemeColor"] = None cancel_type: Optional[str] = None cancel_on_click: Optional[str] = "" cancel_attrs: Optional[dict] = None # UX close_on_esc: Optional[bool] = True close_on_click_outside: Optional[bool] = True # Slots slot_activator: Optional[str] = None slot_prepend: Optional[str] = None slot_title: Optional[str] = None slot_content: Optional[str] = None slot_append: Optional[str] = None @registry.library.simple_tag(takes_context=True) def dialog(context: Context, data: DialogData): is_model_overriden = bool(data.model) model = data.model or "open" # Modify "attrs" passed to buttons, so we close the dialog when clicking the buttons cancel_attrs = { **(data.cancel_attrs or {}), "@click": construct_btn_onclick(model, data.cancel_on_click), } confirm_attrs = { **(data.confirm_attrs or {}), "@click": construct_btn_onclick(model, data.confirm_on_click), } confirm_button_data = ButtonData( variant=data.confirm_variant, # type: ignore[arg-type] color=data.confirm_color, # type: ignore[arg-type] disabled=data.confirm_disabled, href=data.confirm_href, type=data.confirm_type, attrs=confirm_attrs, slot_content=data.confirm_text, ) cancel_button_data = ButtonData( variant=data.cancel_variant, # type: ignore[arg-type] color=data.cancel_color, # type: ignore[arg-type] disabled=data.cancel_disabled, href=data.cancel_href, type=data.cancel_type, attrs=cancel_attrs, slot_content=data.cancel_text, ) with context.push( { "model": model, "is_model_overriden": is_model_overriden, # Classes and HTML attributes "attrs": data.attrs, "activator_attrs": data.activator_attrs, "content_attrs": data.content_attrs, "title_attrs": data.title_attrs, # UX "close_on_esc": data.close_on_esc, "close_on_click_outside": data.close_on_click_outside, # Confirm button "confirm_hide": data.confirm_hide, "confirm_text": data.confirm_text, "confirm_href": data.confirm_href, "confirm_disabled": data.confirm_disabled, "confirm_variant": data.confirm_variant, "confirm_color": data.confirm_color, "confirm_type": data.confirm_type, "confirm_attrs": confirm_attrs, "confirm_button_data": confirm_button_data, # Cancel button "cancel_hide": data.cancel_hide, "cancel_text": data.cancel_text, "cancel_href": data.cancel_href, "cancel_disabled": data.cancel_disabled, "cancel_variant": data.cancel_variant, "cancel_color": data.cancel_color, "cancel_type": data.cancel_type, "cancel_attrs": cancel_attrs, "cancel_button_data": cancel_button_data, # Slots "slot_activator": data.slot_activator, "slot_prepend": data.slot_prepend, "slot_title": data.slot_title, "slot_content": data.slot_content, "slot_append": data.slot_append, } ): return lazy_load_template(dialog_template_str).render(context) ##################################### # TAGS ##################################### class TagEntry(NamedTuple): tag: str selected: bool = False # JS props that can be passed to the Alpine component via python class TagsJsProps(TypedDict): initTags: str tags_template_str: types.django_html = """
{{ slot_title }} {% if editable %}
{% button add_tag_button_data %}
{% endif %}
""" class TagsData(NamedTuple): tag_type: str js_props: dict editable: bool = True max_width: Union[int, str] = "300px" attrs: Optional[dict] = None slot_title: Optional[str] = None @registry.library.simple_tag(takes_context=True) def tags(context: Context, data: TagsData): all_tags = TAG_TYPE_META[data.tag_type.upper()].allowed_values # type: ignore[index] remove_button_data = ButtonData( color="error", attrs={ "class": "!py-1", "@click": "removeTag(index)", }, slot_content="Remove", ) add_tag_button_data = ButtonData( attrs={ "class": "!py-1", "@click": "addTag", }, slot_content="Add tag", ) slot_title = ( data.slot_title or """

Tags:

""" ) with context.push( { "editable": data.editable, "all_tags": all_tags, "max_width": data.max_width, "attrs": data.attrs, "js_props": data.js_props, "remove_button_data": remove_button_data, "add_tag_button_data": add_tag_button_data, "slot_title": slot_title, } ): return lazy_load_template(tags_template_str).render(context) ##################################### # FORM ##################################### form_template_str: types.django_html = """
<{{ form_content_tag }} @click="updateFormModel" @change="updateFormModel" {% html_attrs form_content_attrs %} > {{ slot_form }} {{ slot_below_form }} {% if not actions_hide %}
{{ slot_actions_prepend }} {% if not submit_hide %} {% button submit_button_data %} {% endif %} {% if not cancel_hide %} {% button cancel_button_data %} {% endif %} {{ slot_actions_append }}
{% endif %}
""" class FormData(NamedTuple): type: Literal["table", "paragraph", "ul", None] = None editable: bool = True method: str = "post" # Submit btn submit_hide: Optional[bool] = None submit_text: Optional[str] = "Submit" submit_href: Optional[str] = None submit_disabled: Optional[bool] = None submit_variant: Optional["ThemeVariant"] = "primary" submit_color: Optional["ThemeColor"] = None submit_type: Optional[str] = "submit" submit_attrs: Optional[dict] = None # Cancel btn cancel_hide: Optional[bool] = None cancel_text: Optional[str] = "Cancel" cancel_href: Optional[str] = None cancel_disabled: Optional[bool] = None cancel_variant: Optional["ThemeVariant"] = "secondary" cancel_color: Optional["ThemeColor"] = None cancel_type: Optional[str] = "button" cancel_attrs: Optional[dict] = None # Actions actions_hide: Optional[bool] = None actions_attrs: Optional[dict] = None # Other form_content_attrs: Optional[dict] = None attrs: Optional[dict] = None # Slots slot_actions_prepend: Optional[str] = None slot_actions_append: Optional[str] = None slot_form: Optional[str] = None slot_below_form: Optional[str] = None @registry.library.simple_tag(takes_context=True) def form(context: Context, data: FormData): if data.type == "table": form_content_tag = "table" elif data.type == "paragraph": form_content_tag = "div" elif data.type == "ul": form_content_tag = "ul" else: form_content_tag = "div" # Add AlpineJS bindings to submit button submit_attrs = { **(data.submit_attrs or {}), ":disabled": "isSubmitting", } submit_button_data = ButtonData( variant=data.submit_variant, # type: ignore[arg-type] color=data.submit_color, # type: ignore[arg-type] disabled=data.submit_disabled, type=data.submit_type, attrs=submit_attrs, slot_content=data.submit_text, ) cancel_button_data = ButtonData( variant=data.cancel_variant, # type: ignore[arg-type] color=data.cancel_color, # type: ignore[arg-type] disabled=data.cancel_disabled, href=data.cancel_href, type=data.cancel_type, attrs=data.cancel_attrs, slot_content=data.cancel_text, ) with context.push( { "form_content_tag": form_content_tag, "form_content_attrs": data.form_content_attrs, "method": data.method, "editable": data.editable, "submit_hide": data.submit_hide, "submit_text": data.submit_text, "submit_href": data.submit_href, "submit_disabled": data.submit_disabled or not data.editable, "submit_variant": data.submit_variant, "submit_color": data.submit_color, "submit_type": data.submit_type, "submit_attrs": submit_attrs, "cancel_hide": data.cancel_hide, "cancel_text": data.cancel_text, "cancel_href": data.cancel_href, "cancel_disabled": data.cancel_disabled, "cancel_variant": data.cancel_variant, "cancel_color": data.cancel_color, "cancel_type": data.cancel_type, "cancel_attrs": data.cancel_attrs, "actions_hide": data.actions_hide, "actions_attrs": data.actions_attrs, "attrs": data.attrs, "cancel_button_data": cancel_button_data, "submit_button_data": submit_button_data, "slot_actions_prepend": data.slot_actions_prepend, "slot_actions_append": data.slot_actions_append, "slot_form": data.slot_form, "slot_below_form": data.slot_below_form, } ): return lazy_load_template(form_template_str).render(context) ##################################### # BREADCRUMBS ##################################### @dataclass(frozen=True) class Breadcrumb: """ Single breadcrumb item used with the `breadcrumb` components. """ value: Any """Value of the menu item to render.""" link: Optional[str] = None """ If set, the item will be wrapped in an `` tag pointing to this link. """ item_attrs: Optional[dict] = None """HTML attributes specific to this item.""" breadcrumbs_template_str: types.django_html = """ """ class BreadcrumbsData(NamedTuple): items: List[Breadcrumb] attrs: Optional[dict] = None @registry.library.simple_tag(takes_context=True) def breadcrumbs(context: Context, data: BreadcrumbsData): with context.push( { "items": data.items, "attrs": data.attrs, } ): return lazy_load_template(breadcrumbs_template_str).render(context) breadcrumbs_tag = breadcrumbs ##################################### # BOOKMARKS ##################################### item_class = "px-4 py-1 text-sm text-gray-900 hover:bg-gray-100 cursor-pointer" menu_items = [ [ MenuItem( value="Edit", link="#", item_attrs={ "class": item_class, ":href": "contextMenuItem.value.edit_url", }, ), ], ] bookmarks_template_str: types.django_html = """
  • {% icon bookmarks_icon_data %}
  • """ class BookmarksData(NamedTuple): project_id: int bookmarks: List[ProjectBookmark] attrs: Optional[dict] = None @registry.library.simple_tag(takes_context=True) def bookmarks(context: Context, data: BookmarksData): bookmark_entries: List[BookmarkData] = [] attachment_entries: List[BookmarkData] = [] for bookmark in data.bookmarks: is_attachment = bookmark["attachment"] is not None if is_attachment: # Send user to the Output tab in Project page and open and scroll # to the relevent output that has the correct attachment. edit_url = ( f"/edit/{data.project_id}/bookmark/{bookmark['id']}" f"?{ProjectPageTabsToQueryParams.OUTPUTS.value}" f"&panel={bookmark['attachment']['output']['id']}" # type: ignore[index] ) else: edit_url = f"/edit/{data.project_id}/bookmark/{bookmark['id']}" entry = BookmarkData( bookmark=BookmarkItem( text=bookmark["text"], url=bookmark["url"], id=bookmark["id"], edit_url=edit_url, ), js={ "onMenuToggle": "onContextMenuToggle", }, ) if is_attachment: attachment_entries.append(entry) else: bookmark_entries.append(entry) create_bookmark_url = f"/create/{data.project_id}/bookmark" bookmarks_icon_data = IconData( name="bookmark", variant="outline", text_attrs={ "class": "py-2 text-sm", }, slot_content="Project Bookmarks", ) add_new_bookmark_icon_data = IconData( name="plus", variant="outline", size=18, href=create_bookmark_url, color=theme.sidebar_link, text_attrs={ "class": "px-2 py-1 text-xs", }, svg_attrs={ "class": "mt-0.5 ml-1", }, slot_content="Add New Bookmark", ) context_menu_data = MenuData( items=menu_items, # type: ignore[arg-type] model="contextMenuItem.value", anchor="contextMenuRef.value", anchor_dir="bottom", list_attrs={ "class": "w-24 ml-8 z-40", }, attrs={ "@click_outside": "onContextMenuClickOutside", }, ) with context.push( { "bookmark_entries": bookmark_entries, "attachment_entries": attachment_entries, "create_bookmark_url": create_bookmark_url, "menu_items": menu_items, "attrs": data.attrs, "theme": theme, "bookmarks_icon_data": bookmarks_icon_data, "add_new_bookmark_icon_data": add_new_bookmark_icon_data, "context_menu_data": context_menu_data, } ): return lazy_load_template(bookmarks_template_str).render(context) bookmarks_tag = bookmarks ##################################### # BOOKMARK ##################################### class BookmarkItem(NamedTuple): id: int text: str url: str edit_url: str bookmark_template_str: types.django_html = """
  • {{ bookmark.text }} {% icon bookmark_icon_data %}
  • """ class BookmarkData(NamedTuple): bookmark: BookmarkItem js: Optional[dict] = None @registry.library.simple_tag(takes_context=True) def bookmark(context: Context, data: BookmarkData): bookmark_icon_data = IconData( name="ellipsis-vertical", variant="outline", color=theme.sidebar_link, svg_attrs={ "class": "inline", }, text_attrs={ "class": "p-0", }, attrs={ "class": "self-center cursor-pointer", "x-ref": "bookmark_menu", "@click": "onMenuToggle", }, ) with context.push( { "theme": theme, "bookmark": data.bookmark._asdict(), "js": data.js, "bookmark_icon_data": bookmark_icon_data, } ): return lazy_load_template(bookmark_template_str).render(context) ##################################### # LIST ##################################### @dataclass(frozen=True) class ListItem: """ Single menu item used with the `menu` components. Menu items can be divided by a horizontal line to indicate that the items belong together. In code, we specify this by wrapping the item(s) as an array. """ value: Any """Value of the menu item to render.""" link: Optional[str] = None """ If set, the list item will be wrapped in an `` tag pointing to this link. """ attrs: Optional[dict] = None """Any additional attributes to apply to the list item.""" meta: Optional[dict] = None """Any additional data to pass along the list item.""" list_template_str: types.django_html = """ """ # noqa: E501 class ListData(NamedTuple): items: List[ListItem] attrs: Optional[dict] = None item_attrs: Optional[dict] = None # Slots slot_empty: Optional[str] = None @registry.library.simple_tag(takes_context=True, name="list") def list_tag(context: Context, data: ListData): with context.push( { "items": data.items, "attrs": data.attrs, "item_attrs": data.item_attrs, "slot_empty": data.slot_empty, } ): return lazy_load_template(list_template_str).render(context) ##################################### # TABS ##################################### class TabEntry(NamedTuple): header: str content: str disabled: bool = False class TabStaticEntry(NamedTuple): header: str href: str content: Optional[str] disabled: bool = False tabs_impl_template_str: types.django_html = """
    {% for tab in tabs %}
    {{ tab.content|safe }}
    {% endfor %}
    """ # noqa: E501 class TabsImplData(NamedTuple): tabs: List[TabEntry] # Unique name to identify this tabs instance, so we can open/close the tabs # based on the query params. name: Optional[str] = None attrs: Optional[dict] = None header_attrs: Optional[dict] = None content_attrs: Optional[dict] = None @registry.library.simple_tag(takes_context=True) def tabs_impl(context: Context, data: TabsImplData): with context.push( { "attrs": data.attrs, "tabs": data.tabs, "header_attrs": data.header_attrs, "content_attrs": data.content_attrs, "tabs_data": {"name": data.name}, "theme": theme, } ): return lazy_load_template(tabs_impl_template_str).render(context) class TabsData(NamedTuple): # Unique name to identify this tabs instance, so we can open/close the tabs # based on the query params. name: Optional[str] = None attrs: Optional[dict] = None header_attrs: Optional[dict] = None content_attrs: Optional[dict] = None slot_content: Optional[CallableSlot] = None # This is an "API" component, meaning that it's designed to process # user input provided as nested components. But after the input is # processed, it delegates to an internal "implementation" component # that actually renders the content. @registry.library.simple_tag(takes_context=True) def tabs(context: Context, data: TabsData): if not data.slot_content: return "" ProvidedData = NamedTuple("ProvidedData", [("tabs", List[TabEntry]), ("enabled", bool)]) collected_tabs: List[TabEntry] = [] provided_data = ProvidedData(tabs=collected_tabs, enabled=True) with context.push({"_tabs": provided_data}): data.slot_content.render(context) # By the time we get here, all child TabItem components should have been # rendered, and they should've populated the tabs list. return tabs_impl( context, TabsImplData( tabs=collected_tabs, name=data.name, attrs=data.attrs, header_attrs=data.header_attrs, content_attrs=data.content_attrs, ), ) class TabItemData(NamedTuple): header: str disabled: bool = False slot_content: Optional[str] = None # Use this component to define individual tabs inside the default slot # inside the `tab` component. @registry.library.simple_tag(takes_context=True) def tab_item(context, data: TabItemData): # Access the list of tabs registered for parent Tabs component # This raises if we're not nested inside the Tabs component. tab_ctx = context["_tabs"] # We accessed the _tabs context, but we're inside ANOTHER TabItem if not tab_ctx.enabled: raise RuntimeError( "Component 'tab_item' was called with no parent Tabs component. " "Either wrap 'tab_item' in Tabs component, or check if the component " "is not a descendant of another instance of 'tab_item'" ) parent_tabs = tab_ctx.tabs parent_tabs.append( { "header": data.header, "disabled": data.disabled, "content": mark_safe(data.slot_content or "").strip(), } ) return "" tabs_static_template_str: types.django_html = """
    {% if not hide_body %}
    {{ selected_content }}
    {% endif %}
    """ class TabsStaticData(NamedTuple): tabs: List[TabStaticEntry] tab_index: int = 0 hide_body: bool = False attrs: Optional[dict] = None header_attrs: Optional[dict] = None content_attrs: Optional[dict] = None @registry.library.simple_tag(takes_context=True) def tabs_static(context: Context, data: TabsStaticData): selected_content = data.tabs[data.tab_index].content tabs_data = [] for tab_index, tab in enumerate(data.tabs): is_selectd = tab_index == data.tab_index styling = { "tab": "border-b-2 " + theme.tab_active if is_selectd else "", "text": theme.tab_text_active if is_selectd else theme.tab_text_inactive, } tabs_data.append((tab, styling)) with context.push( { "attrs": data.attrs, "tabs_data": tabs_data, "header_attrs": data.header_attrs, "content_attrs": data.content_attrs, "hide_body": data.hide_body, "selected_content": selected_content, "theme": theme, } ): return lazy_load_template(tabs_static_template_str).render(context) ##################################### # PROJECT_INFO ##################################### class ProjectInfoEntry(NamedTuple): title: str value: str project_info_template_str: types.django_html = """
    {# Info section #}

    Project Info

    {% if editable %} {% button edit_project_button_data %} {% endif %}
    {% for key, value in project_info %} {% endfor %}
    {{ key }}: {{ value }}
    {# Status Updates section #} {% project_status_updates status_updates_data %}
    {# Team section #}

    Team

    {% if editable %} {% button edit_team_button_data %} {% endif %}
    {% project_users project_users_data %}
    {# Contacts section #}

    Contacts

    {% if editable %} {% button edit_contacts_button_data %} {% endif %}
    {% if contacts_data %} {% for row in contacts_data %} {% endfor %}
    Name Job Link
    {{ row.contact.name }} {{ row.contact.job }} {% icon link_icon_data %}
    {% else %}

    No entries

    {% endif %}
    """ class ProjectInfoData(NamedTuple): project: Project project_tags: List[str] contacts: List[ProjectContact] status_updates: List[ProjectStatusUpdate] roles_with_users: List[ProjectRole] editable: bool @registry.library.simple_tag(takes_context=True) def project_info(context: Context, data: ProjectInfoData) -> str: project_edit_url = f"/edit/{data.project['id']}/" edit_project_roles_url = f"/edit/{data.project['id']}/roles/" edit_contacts_url = f"/edit/{data.project['id']}/contacts/" create_status_update_url = f"/create/{data.project['id']}/status_update/" contacts_data = [ { "contact": contact, "link_icon_data": IconData( href=f"/contacts/{contact['link_id']}", name="arrow-top-right-on-square", variant="outline", color="text-gray-400 hover:text-gray-500", ), } for contact in data.contacts ] project_info = [ ProjectInfoEntry("Org", data.project["organization"]["name"]), ProjectInfoEntry("Duration", f"{data.project['start_date']} - {data.project['end_date']}"), ProjectInfoEntry("Status", data.project["status"]), ProjectInfoEntry("Tags", ", ".join(data.project_tags) or "-"), ] edit_project_button_data = ButtonData( href=project_edit_url, attrs={"class": "not-prose"}, slot_content="Edit Project", ) edit_team_button_data = ButtonData( href=edit_project_roles_url, attrs={"class": "not-prose"}, slot_content="Edit Team", ) edit_contacts_button_data = ButtonData( href=edit_contacts_url, attrs={"class": "not-prose"}, slot_content="Edit Contacts", ) status_updates_data = ProjectStatusUpdatesData( project_id=data.project["id"], status_updates=data.status_updates, editable=data.editable, ) project_users_data = ProjectUsersData( project_id=data.project["id"], roles_with_users=data.roles_with_users, available_roles=None, available_users=None, editable=False, ) with context.push( { "project_edit_url": project_edit_url, "edit_contacts_url": edit_contacts_url, "edit_project_roles_url": edit_project_roles_url, "create_status_update_url": create_status_update_url, "contacts_data": contacts_data, "project": data.project, "roles_with_users": data.roles_with_users, "project_info": project_info, "status_updates": data.status_updates, "editable": data.editable, "status_updates_data": status_updates_data, "project_users_data": project_users_data, "edit_project_button_data": edit_project_button_data, "edit_team_button_data": edit_team_button_data, "edit_contacts_button_data": edit_contacts_button_data, } ): return lazy_load_template(project_info_template_str).render(context) ##################################### # PROJECT_NOTES ##################################### project_notes_template_str: types.django_html = """

    Notes

    {% if notes_data %}
    {% for note in notes_data %}
    {{ note.timestamp }} {% if editable %} {% icon note.edit_note_icon_data %} {% endif %}

    {{ note.text }}

    Comments {% for comment in note.comments %}
    {{ comment.timestamp }} {% if editable %} {% icon comment.edit_comment_icon_data %} {% endif %}

    {{ comment.text }}

    {% endfor %}
    {% if editable %} {% button note.create_comment_button_data %} {% endif %}
    {% endfor %}
    {% endif %} {% if editable %} {% button create_note_button_data %} {% endif %}
    """ def _make_comments_data(note: ProjectNote, comment: ProjectNoteComment): modified_time_str = format_timestamp(datetime.fromisoformat(comment["modified"])) formatted_modified_by = ( modified_time_str + " " + comment['modified_by']['name'] ) edit_comment_icon_data = IconData( name="pencil-square", variant="outline", href=f"/update/{note['project']['id']}/note/{note['id']}/comment/{comment['id']}/", color="text-gray-400 hover:text-gray-500", ) return { "timestamp": formatted_modified_by, "notes": comment["text"], "edit_comment_icon_data": edit_comment_icon_data, } def _make_notes_data( notes: List[ProjectNote], comments_by_notes: Dict[int, List[ProjectNoteComment]], ): notes_data: List[dict] = [] for note in notes: comments = comments_by_notes.get(note["id"], []) comments_data = [_make_comments_data(note, comment) for comment in comments] edit_note_icon_data = IconData( name="pencil-square", variant="outline", href=f"/edit/{note['project']['id']}/note/{note['id']}/", color="text-gray-400 hover:text-gray-500", ) create_comment_button_data = ButtonData( href=f"/create/{note['project']['id']}/note/{note['id']}/", slot_content="Add comment", ) notes_data.append( { "text": note["text"], "timestamp": note["created"], "edit_note_icon_data": edit_note_icon_data, "comments": comments_data, "create_comment_button_data": create_comment_button_data, } ) return notes_data class ProjectNotesData(NamedTuple): project_id: int notes: List[ProjectNote] comments_by_notes: Dict[int, List[ProjectNoteComment]] editable: bool @registry.library.simple_tag(takes_context=True) def project_notes(context: Context, data: ProjectNotesData) -> str: notes_data = _make_notes_data(data.notes, data.comments_by_notes) create_note_button_data = ButtonData( href=f"/create/{data.project_id}/note/", slot_content="Add Note", ) with context.push( { "create_note_button_data": create_note_button_data, "notes_data": notes_data, "editable": data.editable, } ): return lazy_load_template(project_notes_template_str).render(context) ##################################### # PROJECT_OUTPUTS_SUMMARY ##################################### class AttachmentWithTags(NamedTuple): attachment: ProjectOutputAttachment tags: List[str] class OutputWithAttachments(NamedTuple): output: ProjectOutput attachments: List[AttachmentWithTags] class OutputWithAttachmentsAndDeps(NamedTuple): output: ProjectOutput attachments: List[AttachmentWithTags] dependencies: List[OutputWithAttachments] outputs_summary_expansion_content_template_str: types.django_html = """ {% if outputs %} {% project_outputs outputs_data %} {% else %} No outputs {% endif %} """ project_outputs_summary_template_str: types.django_html = """
    {% for group in groups %} {% expansion_panel group %} {% endfor %}
    """ class ProjectOutputsSummaryData(NamedTuple): project_id: int outputs: List["OutputWithAttachmentsAndDeps"] editable: bool phase_titles: Dict[ProjectPhaseType, str] @registry.library.simple_tag(takes_context=True) def project_outputs_summary(context: Context, data: ProjectOutputsSummaryData) -> str: outputs_by_phase = group_by(data.outputs, lambda output, _: output[0]["phase"]["phase_template"]["type"]) groups: List[ExpansionPanelData] = [] for phase_meta in PROJECT_PHASES_META.values(): phase_outputs = outputs_by_phase.get(phase_meta.type, []) title = data.phase_titles[phase_meta.type] has_outputs = bool(phase_outputs) outputs_data = ProjectOutputsData( outputs=phase_outputs, project_id=data.project_id, editable=data.editable, ) with context.push( { "outputs_data": outputs_data, "outputs": data.outputs, } ): expansion_panel_content = lazy_load_template(outputs_summary_expansion_content_template_str).render(context) # noqa: E501 expansion_panel_data = ExpansionPanelData( open=has_outputs, header_attrs={"class": "flex gap-x-2 prose"}, slot_header=f"""

    {title}

    """, slot_content=expansion_panel_content, ) groups.append(expansion_panel_data) with context.push( { "groups": groups, } ): return lazy_load_template(project_outputs_summary_template_str).render(context) ##################################### # PROJECT_STATUS_UPDATES ##################################### project_status_updates_template_str: types.django_html = """

    Status Updates

    {% if editable %} {% button add_status_button_data %} {% endif %}
    {% if updates_data %}
    {% for update, update_icon_data in updates_data %}
    {{ update.timestamp }} {% if editable %} {% icon update_icon_data %} {% endif %}

    {{ update.text }}

    {% endfor %}
    {% endif %}
    """ def _make_status_update_data(status_update: ProjectStatusUpdate): modified_time_str = format_timestamp(datetime.fromisoformat(status_update["modified"])) formatted_modified_by = ( modified_time_str + " " + status_update['modified_by']['name'] ) return { "timestamp": formatted_modified_by, "text": status_update["text"], "edit_href": f"/edit/{status_update['project']['id']}/status_update/{status_update['id']}", } class ProjectStatusUpdatesData(NamedTuple): project_id: int status_updates: List[ProjectStatusUpdate] editable: bool @registry.library.simple_tag(takes_context=True) def project_status_updates(context: Context, data: ProjectStatusUpdatesData) -> str: create_status_update_url = f"/create/{data.project_id}/status_update" updates_data = [_make_status_update_data(status_update) for status_update in data.status_updates] updates_data = [ ( entry, IconData( name="pencil-square", variant="outline", href=entry["edit_href"], color="text-gray-400 hover:text-gray-500", ), ) for entry in updates_data ] add_status_button_data = ButtonData( href=create_status_update_url, slot_content="Add status update", ) with context.push( { "create_status_update_url": create_status_update_url, "updates_data": updates_data, "editable": data.editable, "add_status_button_data": add_status_button_data, } ): return lazy_load_template(project_status_updates_template_str).render(context) ##################################### # PROJECT USERS ##################################### roles_table_headers = [ TableHeader(key="name", name="Name"), TableHeader(key="role", name="Role"), TableHeader(key="delete", name="", hidden=True), ] class ProjectAddUserForm(ConditionalEditForm): user_id = forms.ChoiceField(required=True, choices=[], label="User") role = forms.ChoiceField(required=True, choices=[]) def __init__( self, editable: bool, available_role_choices: List[Tuple[str, str]], available_user_choices: List[Tuple[str, str]], *args, **kwargs, ): self.editable = editable super().__init__(*args, **kwargs) user_field: forms.ChoiceField = self.fields["user_id"] # type: ignore[assignment] user_field.choices = available_user_choices role_field: forms.ChoiceField = self.fields["role"] # type: ignore[assignment] role_field.choices = available_role_choices user_dialog_title_template_str: types.django_html = """
    Remove from this project? {% icon delete_icon_data %}
    """ project_users_template_str: types.django_html = """
    {% if table_rows %} {% table table_data %} {% endif %} {% if editable %}

    Set project roles

    {{ add_user_form.as_table }}
    {% button set_role_button_data %} {% button cancel_button_data %}
    {% endif %}
    """ class ProjectUsersData(NamedTuple): project_id: int roles_with_users: List[ProjectRole] available_roles: Optional[List[str]] available_users: Optional[List[User]] editable: bool = False @registry.library.simple_tag(takes_context=True) def project_users(context: Context, data: ProjectUsersData) -> str: roles_table_rows = [] for role in data.roles_with_users: user = role["user"] if data.editable: delete_action = project_user_action( context, ProjectUserActionData( project_id=data.project_id, role_id=role["id"], user_name=user["name"], ), ) else: delete_action = "" roles_table_rows.append( create_table_row( cols={ "name": TableCell(user["name"]), "role": TableCell(role["name"]), "delete": delete_action, } ) ) submit_url = f"/submit/{data.project_id}/role/create" project_url = f"/project/{data.project_id}" if data.available_roles: available_role_choices = [(role, role) for role in data.available_roles] else: available_role_choices = [] if data.available_users: available_user_choices = [(str(user["id"]), user["name"]) for user in data.available_users] else: available_user_choices = [] table_data = TableData( headers=roles_table_headers, rows=roles_table_rows, attrs={"@user_delete": "onUserDelete"}, ) set_role_button_data = ButtonData( type="submit", slot_content="Set role", ) cancel_button_data = ButtonData( href=project_url, variant="secondary", slot_content="Go back", ) delete_icon_data = IconData( name="trash", variant="outline", size=18, attrs={"class": "p-2 self-center"}, ) with context.push( { "delete_icon_data": delete_icon_data, } ): user_dialog_title = lazy_load_template(user_dialog_title_template_str).render(context) dialog_data = DialogData( model="isDeleteDialogOpen", confirm_text="Delete", confirm_href="#", confirm_color="error", confirm_attrs={":href": "role.delete_url"}, content_attrs={"class": "w-full"}, slot_content="
    This action cannot be undone.
    ", slot_title=user_dialog_title, ) with context.push( { "editable": data.editable, "table_headers": roles_table_headers, "table_rows": roles_table_rows, "add_user_form": ProjectAddUserForm( data.editable, available_role_choices, available_user_choices, ), "submit_url": submit_url, "project_url": project_url, "table_data": table_data, "set_role_button_data": set_role_button_data, "cancel_button_data": cancel_button_data, "dialog_data": dialog_data, } ): return lazy_load_template(project_users_template_str).render(context) ##################################### # PROJECT_USER_ACTION ##################################### project_user_action_template_str: types.django_html = """
    {% icon delete_icon_data %}
    """ class ProjectUserActionData(NamedTuple): project_id: int role_id: int user_name: str @registry.library.simple_tag(takes_context=True) def project_user_action(context: Context, data: ProjectUserActionData) -> str: delete_url = f"/delete/{data.project_id}/{data.role_id}" role_data = { "delete_url": delete_url, "role_id": data.role_id, "user_name": data.user_name, } delete_icon_data = IconData( name="trash", variant="outline", size=18, href="#", color="text-gray-500 hover:text-gray-400", svg_attrs={"class": "inline mb-1"}, attrs={ "class": "p-2", "@click.stop": "$dispatch('user_delete', { role })", }, ) with context.push( { "role": role_data, "delete_icon_data": delete_icon_data, } ): return lazy_load_template(project_user_action_template_str).render(context) ##################################### # PROJECT_OUTPUTS ##################################### output_expansion_panel_content_template_str: types.django_html = """
    {# Dependencies #} {% for dep_data in dependencies_data %} {% project_output_dependency dep_data %} {% endfor %} {# Own data + attachments #} {% project_output_form output_form_data %}
    """ project_outputs_template_str: types.django_html = """
    {% for data, output_badge_data, expansion_panel_data in outputs_data %}
    {% project_output_badge output_badge_data %}
    {% expansion_panel expansion_panel_data %}
    {% endfor %}
    """ class ProjectOutputsData(NamedTuple): project_id: int outputs: List[OutputWithAttachmentsAndDeps] editable: bool @registry.library.simple_tag(takes_context=True) def project_outputs(context: Context, data: ProjectOutputsData) -> str: outputs_data: List[Tuple[RenderedProjectOutput, ProjectOutputBadgeData, ExpansionPanelData]] = [] for output_tuple in data.outputs: output, attachments, dependencies = output_tuple attach_data: List[RenderedAttachment] = [] for attachment in attachments: attach_data.append( RenderedAttachment( url=attachment[0]["url"], text=attachment[0]["text"], tags=attachment[1], ) ) update_output_url = "/update" deps: List[RenderedOutputDep] = [] for dep in dependencies: output, attachments = dep phase_url = f"/phase/{data.project_id}/{output['phase']['phase_template']['type']}" deps.append( RenderedOutputDep( dependency=dep, phase_url=phase_url, attachments=[ { "url": d.attachment["url"], "text": d.attachment["text"], "tags": d.tags, } for d in attachments ], ) ) has_missing_deps = any([not output["completed"] for output, _ in dependencies]) output_badge_data = ProjectOutputBadgeData( completed=output["completed"], missing_deps=has_missing_deps, ) output_data = RenderedProjectOutput( output=output, dependencies=deps, has_missing_deps=has_missing_deps, output_data={ "editable": data.editable, }, attachments=attach_data, update_output_url=update_output_url, ) output_form_data = ProjectOutputFormData( data=output_data, editable=data.editable, ) with context.push( { "dependencies_data": [ProjectOutputDependencyData(dependency=dep) for dep in deps], "output_form_data": output_form_data, } ): output_expansion_panel_content = lazy_load_template(output_expansion_panel_content_template_str).render(context) # noqa: E501 expansion_panel_data = ExpansionPanelData( panel_id=output["id"], # type: ignore[arg-type] icon_position="right", attrs={"class": "border-b border-solid border-gray-300 pb-2 mb-3"}, header_attrs={"class": "flex align-center justify-between"}, slot_header=f"""
    {output['name']}
    """, slot_content=output_expansion_panel_content, ) outputs_data.append( ( output_data, output_badge_data, expansion_panel_data, ) ) with context.push( { "outputs_data": outputs_data, } ): return lazy_load_template(project_outputs_template_str).render(context) ##################################### # PROJECT_OUTPUT_BADGE ##################################### project_output_badge_template_str: types.django_html = """ {# Missing dependencies #} {% if missing_deps %} {% icon missing_icon_data %} {# Completed #} {% elif completed %} {% icon completed_icon_data %} {# NOT completed #} {% else %} {% endif %} """ # noqa: E501 class ProjectOutputBadgeData(NamedTuple): completed: bool missing_deps: bool @registry.library.simple_tag(takes_context=True) def project_output_badge(context: Context, data: ProjectOutputBadgeData): missing_icon_data = IconData( name="exclamation-triangle", variant="outline", color="text-black", size=32, stroke_width=2, attrs={"title": "A dependent dependency has not been met!"}, ) completed_icon_data = IconData( name="check", variant="outline", color="text-white", size=20, stroke_width=2, attrs={"class": "p-2"}, ) with context.push( { "completed": data.completed, "missing_deps": data.missing_deps, "theme": theme, "missing_icon_data": missing_icon_data, "completed_icon_data": completed_icon_data, } ): return lazy_load_template(project_output_badge_template_str).render(context) ##################################### # PROJECT_OUTPUT_DEPENDENCY ##################################### project_output_dependency_template_str: types.django_html = """
    {% if dependency.output.completed %} {% if dependency.output.description %} {{ dependency.output.description }} {% else %} {{ OUTPUT_DESCRIPTION_PLACEHOLDER }} {% endif %} {% else %} {% icon warning_icon_data %} Missing '{{ dependency.output.name }}' from {% button missing_button_data %} {% endif %}
    {# Attachments of parent dependencies #} {% project_output_attachments parent_attachments_data %}
    """ class ProjectOutputDependencyData(NamedTuple): dependency: "RenderedOutputDep" @registry.library.simple_tag(takes_context=True) def project_output_dependency(context: Context, data: ProjectOutputDependencyData): warning_icon_data = IconData( name="exclamation-triangle", variant="outline", size=24, stroke_width=2, color="text-gray-500", attrs={"class": "float-left pr-1"}, ) missing_button_data = ButtonData( variant="plain", href=data.dependency.phase_url, attrs={ "target": "_blank", "class": "hover:text-gray-600 !underline", }, slot_content=title(data.dependency.dependency[0]["phase"]["phase_template"]["type"]), ) parent_attachments_data = ProjectOutputAttachmentsData( editable=False, has_attachments=bool(data.dependency.attachments), js_props={"attachments": "attachments.value"}, ) with context.push( { "attachments": data.dependency.attachments, "dependency": data.dependency.dependency, "phase_url": data.dependency.phase_url, "OUTPUT_DESCRIPTION_PLACEHOLDER": OUTPUT_DESCRIPTION_PLACEHOLDER, "warning_icon_data": warning_icon_data, "missing_button_data": missing_button_data, "parent_attachments_data": parent_attachments_data, } ): return lazy_load_template(project_output_dependency_template_str).render(context) ##################################### # PROJECT_OUTPUT_ATTACHMENTS ##################################### class ProjectOutputAttachmentsJsProps(TypedDict): attachments: str project_output_attachments_template_str: types.django_html = """
    {% if not has_attachments and editable %} This output does not have any attachments, create one below: {% elif not has_attachments and not editable %} This output does not have any attachments. {% elif has_attachments and not editable %} Attachments: {% else %} {# NOTE: Else branch required by django-shouty #} {% endif %}
    """ class ProjectOutputAttachmentsData(NamedTuple): has_attachments: bool js_props: ProjectOutputAttachmentsJsProps editable: bool attrs: Optional[dict] = None @registry.library.simple_tag(takes_context=True) def project_output_attachments(context: Context, data: ProjectOutputAttachmentsData): attachment_preview_button_data = ButtonData( variant="plain", link=True, attrs={ "x-bind:href": "attachment.url", "x-text": "attachment.text", "target": "_blank", "class": "hover:text-gray-600 !underline", "style": "color: cornflowerblue;", }, ) edit_button_data = ButtonData( attrs={ "class": "!py-1", "x-text": "attachment.isPreview ? 'Edit' : 'Preview'", "@click": "() => $emit('toggleAttachment', index)", }, slot_content="Edit", ) remove_button_data = ButtonData( color="error", attrs={ "class": "!py-1", "@click": "() => $emit('removeAttachment', index)", }, slot_content="Remove", ) tags_data = TagsData( tag_type="project_output_attachment", editable=data.editable, js_props={ "initTags": "attachment.tags", "onChange": "(tags) => $emit('setAttachmentTags', index, tags)", }, attrs={ "class": "pb-8", }, ) with context.push( { "has_attachments": data.has_attachments, "editable": data.editable, "attrs": data.attrs, "js_props": data.js_props, "text_max_len": FORM_SHORT_TEXT_MAX_LEN, "attachment_preview_button_data": attachment_preview_button_data, "edit_button_data": edit_button_data, "remove_button_data": remove_button_data, "tags_data": tags_data, } ): return lazy_load_template(project_output_attachments_template_str).render(context) ##################################### # PROJECT_OUTPUT_FORM ##################################### OUTPUT_DESCRIPTION_PLACEHOLDER = "Placeholder text" class RenderedAttachment(NamedTuple): url: str text: str tags: List[str] class RenderedOutputDep(NamedTuple): dependency: OutputWithAttachments phase_url: str attachments: List[dict] class RenderedProjectOutput(NamedTuple): output: ProjectOutput dependencies: List[RenderedOutputDep] has_missing_deps: bool output_data: dict attachments: List[RenderedAttachment] update_output_url: str form_content_template_str: types.django_html = """ {# Output description - editable #} {% if editable %} {% else %} {# Output description - readonly #}
    {% if data.output.description %} {{ data.output.description }} {% else %} {{ OUTPUT_DESCRIPTION_PLACEHOLDER }} {% endif %}
    {% endif %}
    Completed: {# NOTE: See https://stackoverflow.com/a/1992745/9788634 #}
    {% if editable %} {% button add_attachment_button_data %} {% button save_button_data %} {% endif %}
    {% project_output_attachments project_output_attachments_data %} """ project_output_form_template_str: types.django_html = """
    {% form form_data %}
    """ class ProjectOutputFormData(NamedTuple): data: RenderedProjectOutput editable: bool @registry.library.simple_tag(takes_context=True) def project_output_form(context: Context, data: ProjectOutputFormData): project_output_attachments_data = ProjectOutputAttachmentsData( has_attachments=bool(data.data.attachments), editable=data.editable, js_props={ "attachments": "attachments.value", "onToggleAttachment": "(index) => toggleAttachmentPreview(index)", "onSetAttachmentTags": "(index, tags) => setAttachmentTags(index, tags)", "onUpdateAttachmentData": "(index, data) => updateAttachmentData(index, data)", "onRemoveAttachment": "(index) => removeAttachment(index)", }, # type: ignore[typeddict-unknown-key] ) save_button_data = ButtonData( attrs={ "@click": "onOutputSubmit({ reload: true })", }, slot_content="Save", ) add_attachment_button_data = ButtonData( variant="secondary", attrs={"@click": "addAttachment"}, slot_content="Add attachment", ) with context.push( { "data": data.data, "editable": data.editable, "OUTPUT_DESCRIPTION_PLACEHOLDER": OUTPUT_DESCRIPTION_PLACEHOLDER, "project_output_attachments_data": project_output_attachments_data, "save_button_data": save_button_data, "add_attachment_button_data": add_attachment_button_data, } ): form_content = lazy_load_template(form_content_template_str).render(context) form_data = FormData( submit_href=data.data.update_output_url, actions_hide=True, slot_form=form_content, ) with context.push( { "form_data": form_data, "alpine_attachments": [d._asdict() for d in data.data.attachments], } ): return lazy_load_template(project_output_form_template_str).render(context) ##################################### # # IMPLEMENTATION END # ##################################### # DO NOT REMOVE - See https://github.com/django-components/django-components/pull/999 # ----------- TESTS START ------------ # # The code above is used also used when benchmarking. # The section below is NOT included. from django_components.testing import djc_test # noqa: E402 @djc_test def test_render(snapshot): data = gen_render_data() rendered = render(data) assert rendered == snapshot