django-components/tests/test_benchmark_djc.py
Juro Oravec 8677ee7941
refactor: deprecate template caching, get_template_name, get_template, assoc template with Comp cls (#1222)
* refactor: deprecate template caching, get_template_name, get_template, assoc template with Comp cls

* refactor: change implementation

* refactor: handle cached template loader

* refactor: fix tests

* refactor: fix test on windows

* refactor: try to  fix type errors

* refactor: Re-cast `context` to fix type errors

* refactor: fix linter error

* refactor: fix typing

* refactor: more linter fixes

* refactor: more linter errors

* refactor: revert extra node metadata
2025-06-01 19:20:22 +02:00

6052 lines
195 KiB
Python

# 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 itertools import chain
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.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 Component, registry, register, types
# 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
#####################################
# 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):
result = ProjectPage.render(kwargs=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
#####################################
@register("Button")
class Button(Component):
def get_context_data(
self,
/,
*,
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,
):
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 variant == "plain":
all_css_class = common_css
else:
button_classes = get_styling_css(variant, color, 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 disabled and (href or link)
all_attrs = {
**(attrs or {})
}
if disabled:
all_attrs["aria-disabled"] = "true"
return {
"href": href,
"disabled": disabled,
"type": type,
"btn_class": all_css_class,
"attrs": all_attrs,
"is_link": is_link,
}
template: types.django_html = """
{# Based on buttons from https://tailwindui.com/components/application-ui/overlays/modals #}
{% if is_link %}
<a
href="{{ href }}"
{% html_attrs attrs class=btn_class class="no-underline" %}
>
{% else %}
<button
type="{{ type }}"
{% if disabled %} disabled {% endif %}
{% html_attrs attrs class=btn_class %}
>
{% endif %}
{% slot "content" default / %}
{% if is_link %}
</a>
{% else %}
</button>
{% endif %}
"""
#####################################
# 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 `<a>` tag pointing to this
link.
"""
item_attrs: Optional[dict] = None
"""HTML attributes specific to this menu item."""
@register("Menu")
class Menu(Component):
def get_context_data(
self,
/,
*,
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",
):
is_model_overriden = bool(model)
model = model or "open"
all_list_attrs: dict = {}
if list_attrs:
all_list_attrs.update(list_attrs)
if anchor:
all_list_attrs[f"x-anchor.{anchor_dir}"] = anchor
all_list_attrs.update({
"x-show": model,
"x-cloak": "",
})
return {
"model": model,
"items": items,
"is_model_overriden": is_model_overriden,
"close_on_click_outside": close_on_click_outside,
"close_on_esc": close_on_esc,
"activator_attrs": activator_attrs,
"list_attrs": all_list_attrs,
"attrs": attrs,
}
template: 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 %}
<div
{% html_attrs attrs %}
x-data="{
'isModelOverriden': {{ is_model_overriden|alpine }},
'modelName': {{ model|alpine }},
'closeOnClickOutside': {{ close_on_click_outside|alpine }},
{% if not is_model_overriden %}
'{{ model }}': false,
{% endif %}
onClickOutside(event) {
if (this.closeOnClickOutside) {
if (!this.isModelOverriden) {
this[this.modelName] = false;
}
$dispatch('click_outside', { origEvent: event });
}
},
}"
{% if close_on_esc %}
@keydown.escape="{{ model }} = false"
{% endif %}
>
{# This is what opens the modal #}
{% if component_vars.is_filled.activator or component_vars.is_filled.default %}
<div
@click="{{ model }} = !{{ model }}"
@keydown.enter="{{ model }} = !{{ model }}"
tabindex="0"
aria-haspopup="true"
:aria-expanded="!!{{ model }}"
x-ref="activator"
{% html_attrs activator_attrs %}
>
{% slot "activator" default / %}
</div>
{% endif %}
{% component "MenuList" items=items attrs=list_attrs / %}
</div>
"""
#####################################
# 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
@register("MenuList")
class MenuList(Component):
def get_context_data(
self,
/,
*,
items: MaybeNestedList[Union[MenuItem, str]],
attrs: Optional[dict] = None,
):
item_groups = prepare_menu_items(items)
return {
"item_groups": item_groups,
"attrs": attrs,
}
template: types.django_html = """
{# Based on https://tailwindui.com/components/application-ui/elements/dropdowns #}
<div
role="menu"
aria-orientation="vertical"
{% html_attrs attrs class="mt-2 divide-y divide-gray-300 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none" %}
>
{% for group in item_groups %}
<div class="py-1" role="group">
{% for item in group %}
{% if item.link %}
<a
role="menuitem"
tabindex="0"
href="{{ item.link }}"
{% html_attrs item.item_attrs class="block" %}
>
{{ item.value }}
</a>
{% else %}
<div
role="menuitem"
tabindex="0"
{% html_attrs item.item_attrs %}
>
{{ item.value }}
</div>
{% endif %}
{% endfor %}
</div>
{% endfor %}
</div>
""" # noqa: E501
#####################################
# 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 `<a>` tag pointing to this
link.
"""
link_attrs: Optional[dict] = None
"""
HTML attributes for the `<a>` 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
@register("Table")
class Table(Component):
def get_context_data(
self,
/,
*,
headers: List[TableHeader],
rows: List[TableRow],
attrs: Optional[dict] = None,
):
rows_to_render = [
tuple([row, prepare_row_headers(row, headers)]) for row in rows
]
return {
"headers": headers,
"rows_to_render": rows_to_render,
"NULL_CELL": NULL_CELL,
"attrs": attrs,
}
template: types.django_html = """
<div {% html_attrs attrs class="flow-root" %}>
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<table class="min-w-full divide-y divide-gray-300">
<thead>
<tr>
{% for header in headers %}
<th
scope="col"
{% html_attrs
header.cell_attrs
class="text-left text-sm font-semibold text-gray-900 py-3.5"
class="{% if forloop.first %} pl-4 pr-3 sm:pl-0 {% else %} px-3 {% endif %}"
%}
>
{% if header.hidden %}
<span class="sr-only"> {{ header.name }} </span>
{% else %}
{{ header.name }}
{% endif %}
</th>
{% endfor %}
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{% for row, row_headers in rows_to_render %}
<tr {% html_attrs row.row_attrs %}>
{% for header in row_headers %}
{% define row.cols|get_item:header.key|default_if_none:NULL_CELL as cell %}
<td
colspan="{{ cell.colspan }}"
{% html_attrs cell.cell_attrs row.col_attrs %}
>
{% if cell.link %}
<a
href="{{ cell.link }}"
{% html_attrs cell.link_attrs %}
>
{% if cell.linebreaks %}
{{ cell.value | linebreaksbr }}
{% else %}
{{ cell.value }}
{% endif %}
</a>
{% else %}
{% if cell.linebreaks %}
{{ cell.value | linebreaksbr }}
{% else %}
{{ cell.value }}
{% endif %}
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
""" # noqa: E501
#####################################
# ICON
#####################################
@register("Icon")
class Icon(Component):
def get_context_data(
self,
/,
*,
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,
):
# Allow to set icon and text independently, or both at same time via `color` prop
if not icon_color:
icon_color = color
if not text_color:
text_color = color
svg_attrs = svg_attrs.copy() if 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"
return {
"name": name,
"variant": variant,
"size": size,
"viewbox": viewbox,
"stroke_width": stroke_width,
"svg_attrs": svg_attrs,
"text_color": text_color,
"text_attrs": text_attrs,
"link_attrs": link_attrs,
"href": href,
"attrs": attrs,
}
template: types.django_html = """
<div {% html_attrs attrs %}>
{% if href %}
<a
href="{{ href }}"
{% html_attrs
link_attrs
text_attrs
class=text_color
class="group flex gap-x-3 rounded-md text-sm leading-6 font-semibold"
%}
>
{% else %}
<span
{% html_attrs
text_attrs
class=text_color
class="group flex gap-x-3 rounded-md text-sm leading-6 font-semibold"
%}
>
{% endif %}
{% component "heroicons"
name=name
variant=variant
size=size
viewbox=viewbox
stroke_width=stroke_width
attrs=svg_attrs
/ %}
{% slot "content" default / %}
{% if href %}
</a>
{% else %}
</span>
{% endif %}
</div>
"""
#####################################
# 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
@register("heroicons")
class HeroIcon(Component):
"""The icon component"""
template: types.django_html = """
{% load component_tags %}
<svg {% html_attrs attrs default_attrs %}>
{% for path_attrs in icon_paths %}
<path {% html_attrs path_attrs %} />
{% endfor %}
</svg>
"""
def get_context_data(
self,
/,
*,
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,
) -> Dict:
kwargs = IconDefaults(**self.input.kwargs)
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"
return {
"icon_paths": icon_paths,
"default_attrs": default_attrs,
"attrs": kwargs.attrs,
}
#####################################
# EXPANSION PANEL
#####################################
@register("ExpansionPanel")
class ExpansionPanel(Component):
def get_context_data(
self,
/,
*,
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",
):
init_data = {"open": open}
return {
"attrs": attrs,
"header_attrs": header_attrs,
"content_attrs": content_attrs,
"icon_position": icon_position,
"init_data": init_data,
"panel_id": panel_id if panel_id else False,
}
template: types.django_html = """
<div
x-data="expansion_panel"
data-init="{{ init_data|json|escape }}"
{% html_attrs attrs data-panelid=panel_id %}
>
<div
@click="togglePanel"
{% html_attrs header_attrs class="pb-2 cursor-pointer" %}
>
{% if icon_position == "left" %}
{% component "Icon"
name="chevron-down"
variant="outline"
attrs:style="width: fit-content;"
attrs::class="{ 'rotate-180': isOpen }"
/ %}
{% endif %}
{% slot "header" / %}
{% if icon_position == "right" %}
{% component "Icon"
name="chevron-down"
variant="outline"
attrs:style="width: fit-content;"
attrs::class="{ 'rotate-180': isOpen }"
/ %}
{% endif %}
</div>
<div x-show="isOpen" {% html_attrs content_attrs %}>
{% slot "content" default / %}
</div>
</div>
"""
js: types.js = """
document.addEventListener("alpine:init", () => {
Alpine.data("expansion_panel", () => ({
// Variables
isOpen: false,
// Methods
init() {
const initDataStr = this.$el.dataset.init;
const initData = JSON.parse(initDataStr);
this.isOpen = initData.open;
const panelId = this.$el.dataset.panelid;
const panel = new URL(location.href).searchParams.get("panel");
if (panel && panel == panelId) {
this.isOpen = true;
this.$el.scrollIntoView();
}
},
togglePanel(event) {
this.isOpen = !this.isOpen;
},
}));
});
"""
#####################################
# 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"}
@register("ProjectPage")
class ProjectPage(Component):
def get_context_data(
self,
/,
*,
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,
):
rendered_phases: List[ListItem] = []
phases_by_type = {p['phase_template']['type']: p for p in phases}
for phase_meta in PROJECT_PHASES_META.values():
phase = phases_by_type[phase_meta.type]
title = phase_titles[phase_meta.type]
rendered_phases.append(
ListItem(
value=title,
link=f"/projects/{project['id']}/phases/{phase['phase_template']['type']}",
)
)
redirect_url = f"/projects/{project['id']}"
return {
"layout_data": layout_data,
"project": project,
"breadcrumbs": breadcrumbs or [],
"project_tags": project_tags,
"rendered_phases": rendered_phases,
"contacts": contacts,
"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,
"status_updates": status_updates,
"roles_with_users": roles_with_users,
"outputs": outputs,
"user_is_project_member": user_is_project_member,
"user_is_project_owner": user_is_project_owner,
"project_page_url": redirect_url,
"phase_titles": phase_titles,
}
template: types.django_html = """
{% component "ProjectLayoutTabbed"
data=layout_data
breadcrumbs=breadcrumbs
top_level_tab_index=1
%}
{% fill "header" %}
<div class="flex pb-6">
<div class="flex justify-between gap-x-12">
<div class="prose">
<h3>{{ project.name }}</h3>
</div>
<div class="prose font-semibold text-gray-500 pt-1">
{{ project.start_date }} - {{ project.end_date }}
</div>
</div>
</div>
{% endfill %}
{% fill "left_panel" %}
{% component "List"
items=rendered_phases
item_attrs:class="py-5"
/ %}
{% endfill %}
{% fill "tabs" %}
{% component "TabItem" header="Project Info" %}
{% component "ProjectInfo"
project=project
project_tags=project_tags
roles_with_users=roles_with_users
contacts=contacts
status_updates=status_updates
editable=user_is_project_owner
/ %}
{% endcomponent %}
{% component "TabItem" header="Notes 1" %}
{% component "ProjectNotes"
project_id=project.id
notes=notes_1
comments_by_notes=comments_by_notes_1
editable=user_is_project_member
/ %}
{% endcomponent %}
{% component "TabItem" header="Notes 2" %}
{% component "ProjectNotes"
project_id=project.id
notes=notes_2
comments_by_notes=comments_by_notes_2
editable=user_is_project_member
/ %}
{% endcomponent %}
{% component "TabItem" header="Notes 3" %}
{% component "ProjectNotes"
project_id=project.id
notes=notes_3
comments_by_notes=comments_by_notes_3
editable=user_is_project_member
/ %}
{% endcomponent %}
{% component "TabItem" header="Outputs" %}
{% component "ProjectOutputsSummary"
project_id=project.id
outputs=outputs
editable=user_is_project_member
phase_titles=phase_titles
/ %}
{% endcomponent %}
{% endfill %}
{% endcomponent %}
"""
#####################################
# 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,
),
]
@register("ProjectLayoutTabbed")
class ProjectLayoutTabbed(Component):
def get_context_data(
self,
/,
*,
data: ProjectLayoutData,
breadcrumbs: Optional[List["Breadcrumb"]] = None,
top_level_tab_index: Optional[int] = None,
variant: Literal["thirds", "halves"] = "thirds",
):
projects_url = "/projects"
curr_project_url = f"/projects/{data.project['id']}"
prefixed_breadcrumbs = chain(
[
Breadcrumb(
link=projects_url,
value=Icon.render(
kwargs={
"name": "home",
"variant": "outline",
"size": 20,
"stroke_width": 2,
"color": "text-gray-400 hover:text-gray-500",
},
deps_strategy="ignore",
),
),
Breadcrumb(value=data.project["name"], link=curr_project_url),
],
breadcrumbs or [],
)
top_level_tabs = gen_tabs(data.project["id"])
left_pannel_attrs = {
"class": "w-1/3" if variant == "thirds" else "w-1/2",
}
right_pannel_attrs = {
"class": "w-2/3" if variant == "thirds" else "w-1/2",
}
return {
"layout_data": data,
"breadcrumbs": prefixed_breadcrumbs,
"bookmarks": data.bookmarks,
"project": data.project,
"top_level_tabs": top_level_tabs,
"top_level_tab_index": top_level_tab_index,
"theme": theme,
"left_pannel_attrs": left_pannel_attrs,
"right_pannel_attrs": right_pannel_attrs,
}
template: types.django_html = """
{% component "Layout" data=layout_data %}
{% fill "js" %}
{% slot "js" / %}
{% endfill %}
{% fill "css" %}
{% slot "css" / %}
{% endfill %}
{% fill "header" %}
{% component "Breadcrumbs" items=breadcrumbs / %}
{% endfill %}
{% fill "sidebar" %}
{% component "Bookmarks" bookmarks=bookmarks project_id=project.id / %}
{% endfill %}
{% fill "content" %}
{% slot "header" / %}
{% if top_level_tab_index is not None %}
{% component "TabsStatic" tabs=top_level_tabs index=top_level_tab_index / %}
{% endif %}
<div class="flex flex-auto gap-6">
{# Split the content to 2 columns, based on whether `left_panel` slot is filled #}
{% if component_vars.is_filled.left_panel %}
<div {% html_attrs left_pannel_attrs class="relative h-full pb-4" %}>
<div class="absolute w-full h-full">
{% slot "left_panel" / %}
</div>
</div>
<div {% html_attrs right_pannel_attrs class="h-full" %}>
{% endif %}
{% slot "content" default %}
<div class="h-full divide-y divide-gray-200 bg-white shadow overflow-y-hidden">
{% component "Tabs"
name="proj-right"
attrs:class="p-6 h-full"
content_attrs:class="flex flex-col"
%}
{% slot "tabs" / %}
{% endcomponent %}
</div>
{% endslot %}
{% if component_vars.is_filled.left_panel %}
</div>
{% endif %}
</div>
{% endfill %}
{% endcomponent %}
"""
#####################################
# LAYOUT
#####################################
class LayoutData(NamedTuple):
request: HttpRequest
active_projects: List[Project]
@register("Layout")
class Layout(Component):
def get_context_data(
self,
/,
*,
data: LayoutData,
attrs: Optional[dict] = None,
):
return {
"request": data.request,
"active_projects": data.active_projects,
"attrs": attrs,
}
template: types.django_html = """
{% component "RenderContextProvider" request=request %}
{% component "Base" %}
{% fill "js" %}
{% slot "js" / %}
{% endfill %}
{% fill "css" %}
{% slot "css" / %}
{% endfill %}
{% fill "content" %}
<div
x-data="layout"
@resize.window="onWindowResize"
{% html_attrs attrs %}
>
<!-- Static sidebar for desktop -->
<div
class="hidden"
:class="{
'fixed inset-y-0 z-40 flex w-72 flex-col': sidebarOpen,
'hidden': !sidebarOpen,
}"
>
{% component "Sidebar" active_projects=active_projects %}
{% fill "content" %}
{% slot "sidebar" / %}
{% endfill %}
{% endcomponent %}
</div>
<div :class="{ 'pl-72': sidebarOpen }" class="flex flex-col" style="height: 100vh;">
{% component "Navbar" attrs:@sidebar_toggle="toggleSidebar" / %}
<main class="flex-auto flex flex-col">
{% slot "header" / %}
<div class="px-4 pt-10 sm:px-6 lg:px-8 flex-auto flex flex-col">
{% slot "content" default / %}
</div>
</main>
</div>
</div>
{% endfill %}
{% endcomponent %}
{% endcomponent %}
"""
js: types.js = """
document.addEventListener('alpine:init', () => {
// NOTE: Defined as standalone function so we can call it variable initialization
const computeSidebarState = (prevState) => {
const width = (window.innerWidth > 0) ? window.innerWidth : screen.width;
// We automatically hide the sidebar when window is smaller than 1024px
const sidebarBreakpoint = 1024;
if (!prevState && width >= sidebarBreakpoint) {
return true;
} else if (prevState && width < sidebarBreakpoint) {
return false;
} else {
return prevState;
}
};
Alpine.data('layout', () => ({
// Variables
sidebarOpen: computeSidebarState(false),
init() {
this.onWindowResize();
},
// Handlers
toggleSidebar() {
this.sidebarOpen = !this.sidebarOpen;
},
onWindowResize() {
this.sidebarOpen = computeSidebarState(this.sidebarOpen);
},
}));
});
"""
#####################################
# 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
# 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")`.
@register("RenderContextProvider")
class RenderContextProvider(Component):
def get_context_data(
self,
/,
*,
request: HttpRequest,
):
csrf_token = csrf.get_token(request)
context = RenderContext(
request=request,
user=request.user,
csrf_token=csrf_token,
)
return {
"context": context,
}
template: types.django_html = """
{% provide "render_context" render_context=context %}
{% slot "content" default / %}
{% endprovide %}
"""
#####################################
# BASE
#####################################
@register("Base")
class Base(Component):
def get_context_data(self) -> dict:
context: RenderContext = self.inject("render_context").render_context
return {
"csrf_token": context.csrf_token,
"theme": theme,
}
template: types.django_html = """
{% load static %}
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DEMO</title>
{% component_css_dependencies %}
{% slot "css" / %}
</head>
<body class="{{ theme.background }} h-full">
{% slot "content" default / %}
{# AlpineJS + Plugins #}
<script src="//unpkg.com/@alpinejs/anchor" defer></script>
<script src="https://cdn.jsdelivr.net/npm/alpine-reactivity@0.1.10/dist/cdn.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/alpine-composition@0.1.27/dist/cdn.min.js"></script>
<script src="//unpkg.com/alpinejs" defer></script>
{# HTMX #}
<script type="text/javascript" src="{% static 'js/htmx.js' %}"></script>
{# Axios (AJAX) #}
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
{# JS scripts from our custom Django components #}
{% component_js_dependencies %}
{# Any extra scripts #}
{% slot "js" / %}
<script>
{# Configure csrf_token for HTMX #}
(function () {
const token = '{{ csrf_token }}';
document.body.addEventListener('htmx:configRequest', (event) => {
event.detail.headers['X-CSRFToken'] = token;
});
{# Expose csrf_token to AlpineJS #}
document.addEventListener('alpine:init', () => {
Alpine.store('csrf', {
token,
});
});
})();
</script>
</body>
</html>
"""
js: types.js = """
////////////////////////////////////////////////////////////////
// base.js
////////////////////////////////////////////////////////////////
/** Global JS state / methods */
const app = {
// NOTE: queryManager.js MUST be loaded before this script!
query: createQueryManager(),
};
app.query.load();
////////////////////////////////////////////////////////////////
// queryManager.js
////////////////////////////////////////////////////////////////
/**
* Callback when a URL's query param changes.
*
* @callback OnParamChangeCallback
* @param {string | null} newValue - New value of the query param.
* @param {string | null} oldValue - Old value of the query param.
*/
/**
* Function that can be called once to remove the registered callback.
*
* @callback UnregisterFn
*/
/**
* Callback for modifying URL.
*
* @callback OnUrlModifyCallback
* @param {URL} currUrl - Current URL.
* @returns {URL | string} New URL.
*/
/**
* Singular interface for manipulating URL search/query parameters
* and reacting to changes.
*
* See https://developer.mozilla.org/en-US/docs/Web/API/Location/search
*/
const createQueryManager = () => {
/**
* @type {Record<string, OnParamChangeCallback[]>}
*/
const callbacks = {};
/**
* Store previous values of query params, so we can provide both new and old
* values to the callbacks.
*
* NOTE: Use `setParamValue` instead of setting values directly.
*
* @type {Record<string, string | null>}
*/
const previousParamValues = {};
/**
* @param {string} key
* @param {string | null} newValue
*/
const setParamValue = (key, newValue) => {
const oldValue =
previousParamValues[key] === undefined ? null : previousParamValues[key];
previousParamValues[key] = newValue;
const paramCallbacks = callbacks[key] || [];
paramCallbacks.forEach((cb) => cb(newValue, oldValue));
};
/**
* Register a listener that will be triggered when a value changes for the query param
* of given name.
*
* Returns a function that can be called once to remove the registered callback.
*
* @param {string} paramName
* @param {OnParamChangeCallback} callback
* @returns {UnregisterFn}
*/
const registerParam = (paramName, callback) => {
if (callbacks[paramName] == undefined) {
callbacks[paramName] = [];
}
callbacks[paramName].push(callback);
// Run the callback once if the query param already has some value
if (previousParamValues[paramName] != null) {
callback(previousParamValues[paramName], null);
}
// Return a function that can be called once to remove the registered callback
let unregisterCalled = false;
const unregister = () => {
if (unregisterCalled) return;
unregisterCalled = true;
unregisterParam(paramName, callback);
};
return unregister;
};
/**
* Unregister a callback that was previously registered with `registerParam`
* for the query param of given name.
*
* @param {string} paramName
* @param {OnParamChangeCallback} callback
*/
const unregisterParam = (paramName, callback) => {
// Nothing to do
if (callbacks[paramName] == undefined) return;
// Remove one instance of callback from the array to simulate similar behavior
// as browser's addEventListener/removeEventListener.
// See https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
const indexToRemove = callbacks[paramName].indexOf(callback);
if (indexToRemove !== -1) {
callbacks[paramName].splice(indexToRemove, 1);
}
};
/**
* Shared logic for modifying the page's URL in-place (without reload).
*
* @param {OnUrlModifyCallback} mapFn
*/
const modifyUrl = (mapFn) => {
// Prepare current URL
const currUrl = new URL(globalThis.location.href);
// Let the user of this function decide how to transform the URL
let updatedUrl = mapFn(currUrl);
// Update browser URL without reloading the page
// See https://developer.mozilla.org/en-US/docs/Web/API/History/pushState
// And https://stackoverflow.com/a/3354511/9788634
globalThis.history.replaceState(null, "", updatedUrl.toString());
};
/**
* Set query parameters to the URL.
*
* If the URL already contains query params of the same name, these will be overwritten.
*
* @param {Record<string, string>} params
*/
const setParams = (params) => {
modifyUrl((currUrl) => {
Object.entries(params).forEach(([key, val]) => {
currUrl.searchParams.set(key, val);
});
return currUrl.href;
});
// Trigger callbacks for all params that were set.
Object.entries(params).forEach(([key, val]) => setParamValue(key, val));
};
/** Clear all query parameters from the URL. */
const clearParams = () => {
modifyUrl((currUrl) => {
currUrl.search = "";
return currUrl.href;
});
// Trigger callbacks for all params that were unset.
Object.entries(previousParamValues)
.filter(([key, val]) => val !== null)
.forEach(([key, val]) => setParamValue(key, val));
};
/** Load query params from the current page URL, triggering any registered callbacks. */
const load = () => {
const currUrl = new URL(globalThis.location.href);
currUrl.searchParams.forEach((value, key) => setParamValue(key, value));
};
return {
setParams,
clearParams,
registerParam,
unregisterParam,
load,
};
};
////////////////////////////////////////////////////////////////
// submitForm.js
////////////////////////////////////////////////////////////////
/**
* @param {HTMLFormElement} formEl
*/
const getFormData = (formEl) => {
return Object.fromEntries(new FormData(formEl));
};
/**
* @param {HTMLFormElement} formEl
* @param {object} formData
*/
const submitForm = (formEl, data, { reload = false } = {}) => {
// Do not submit anything when the form doesn't specify the target URL
if (!formEl.hasAttribute('action')) Promise.resolve();
return axios.post(formEl.action, data, {
method: formEl.method,
})
.then((response) => {
if (reload) location.reload();
})
.catch((error) => {
console.error(error);
});
};
"""
#####################################
# 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[SidebarItem]:
items: List[SidebarItem] = [
SidebarItem(
name="Homepage",
icon="home",
icon_variant="outline",
href="/",
),
SidebarItem(
name="Projects",
icon="folder",
icon_variant="outline",
href="/projects",
children=[
SidebarItem(
name=project['name'],
icon=None,
href=f"/projects/{project['id']}",
)
for project in active_projects
],
),
SidebarItem(
name="Page 3",
icon="folder",
icon_variant="outline",
href="/page-3",
),
SidebarItem(
name="Page 4",
icon="bars-arrow-down",
icon_variant="outline",
href="/page-4",
),
SidebarItem(
name="page-5",
icon="forward",
icon_variant="outline",
href="/page-5",
),
SidebarItem(
name="FAQ",
icon="archive-box",
icon_variant="outline",
href="/faq",
),
]
return items
@register("Sidebar")
class Sidebar(Component):
def get_context_data(
self,
/,
*,
active_projects: List[Project],
attrs: Optional[dict] = None,
):
context: RenderContext = self.inject("render_context").render_context
user = context.user
items = gen_sidebar_menu_items(active_projects)
faq_url = "/faq"
return {
"items": items,
"attrs": attrs,
"user": user,
"theme": theme,
"faq_url": faq_url,
}
template: types.django_html = """
<div
{% html_attrs
attrs
class="flex grow flex-col gap-y-5 overflow-y-auto px-6 pb-4"
class=theme.sidebar
%}
>
<div class="flex h-16 shrink-0 items-center">
DEMO
</div>
<nav class="flex flex-1 flex-col">
<ul role="list" class="flex flex-1 flex-col gap-y-7">
<li>
{% slot "content" / %}
<ul role="list" class="-mx-2 space-y-1">
{% for sidebar_item in items %}
<li>
{% component "Icon"
name=sidebar_item.icon
variant=sidebar_item.icon_variant
href=sidebar_item.href
color=theme.sidebar_link
text_attrs:class="p-2"
%}
{{ sidebar_item.name }}
{% endcomponent %}
</li>
{% for child_item in sidebar_item.children %}
<li class="ml-8 rounded-md {{ theme.sidebar_link }}">
{% component "Button"
variant="plain"
href=child_item.href
attrs:class="p-2 !w-full"
%}
{{ child_item.name }}
{% endcomponent %}
</li>
{% endfor %}
{% endfor %}
</ul>
<li class="mt-auto">
{% component "Icon"
name='user-group'
variant='outline'
href=faq_url
color=theme.sidebar_link
text_attrs:class="p-2"
%}
FAQ
{% endcomponent %}
{% component "Icon"
name='megaphone'
variant='outline'
color=theme.sidebar_link
link_attrs:target="_blank"
text_attrs:class="p-2"
%}
Feedback
{% endcomponent %}
</li>
{% if user.is_staff %}
<li>
{% component "Icon"
name='document-arrow-down'
variant='outline'
color=theme.sidebar_link
text_attrs:class="p-2"
%}
Download
{% endcomponent %}
</li>
{% endif %}
</li>
</ul>
</nav>
</div>
"""
#####################################
# NAVBAR
#####################################
@register("Navbar")
class Navbar(Component):
def get_context_data(
self,
/,
*,
attrs: Optional[dict] = None,
):
return {
"attrs": attrs,
}
template: types.django_html = """
<div
{% html_attrs
attrs
class="sticky top-0 z-30 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8"
%}
>
<button
type="button"
class="-m-2.5 p-2.5 text-gray-700"
@click="$dispatch('sidebar_toggle')"
>
<span class="sr-only">Open sidebar</span>
{% component "Icon" name='bars-3' variant='outline' / %}
</button>
<!-- Separator -->
<div class="h-6 w-px bg-gray-900/10 lg:hidden" aria-hidden="true"></div>
<div class="flex flex-1 gap-x-4 self-stretch lg:gap-x-6">
{# Search not implemented #}
<form class="relative flex flex-1 items-center" action="#" method="GET">
</form>
<div class="flex items-center gap-x-4 lg:gap-x-6">
<!-- Separator -->
<div
class="hidden lg:block lg:h-6 lg:w-px lg:bg-gray-900/10"
aria-hidden="true"
></div>
</div>
</div>
</div>
""" # noqa: E501
#####################################
# 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)
@register("Dialog")
class Dialog(Component):
def get_context_data(
self,
/,
*,
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,
):
is_model_overriden = bool(model)
model = model or "open"
# Modify "attrs" passed to buttons, so we close the dialog when clicking the buttons
cancel_attrs = {
**(cancel_attrs or {}),
"@click": construct_btn_onclick(model, cancel_on_click),
}
confirm_attrs = {
**(confirm_attrs or {}),
"@click": construct_btn_onclick(model, confirm_on_click),
}
return {
"model": model,
"is_model_overriden": is_model_overriden,
# Classes and HTML attributes
"attrs": attrs,
"activator_attrs": activator_attrs,
"content_attrs": content_attrs,
"title_attrs": title_attrs,
# UX
"close_on_esc": close_on_esc,
"close_on_click_outside": close_on_click_outside,
# Confirm button
"confirm_hide": confirm_hide,
"confirm_text": confirm_text,
"confirm_href": confirm_href,
"confirm_disabled": confirm_disabled,
"confirm_variant": confirm_variant,
"confirm_color": confirm_color,
"confirm_type": confirm_type,
"confirm_attrs": confirm_attrs,
# Cancel button
"cancel_hide": cancel_hide,
"cancel_text": cancel_text,
"cancel_href": cancel_href,
"cancel_disabled": cancel_disabled,
"cancel_variant": cancel_variant,
"cancel_color": cancel_color,
"cancel_type": cancel_type,
"cancel_attrs": cancel_attrs,
}
template: 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 %}
<div
x-data="{
id: $id('modal-title'),
{% if not is_model_overriden %}
'{{ model }}': false,
{% endif %}
}"
{% if close_on_esc %}
@keydown.escape="{{ model }} = false"
{% endif %}
{% html_attrs attrs %}
>
{% if component_vars.is_filled.activator or component_vars.is_filled.default %}
{# This is what opens the modal #}
<div
@click="{{ model }} = true"
{% html_attrs activator_attrs %}
>
{% slot "activator" default / %}
</div>
{% endif %}
<div
class="relative z-50"
:aria-labelledby="id"
role="dialog"
aria-modal="true"
x-cloak
>
<!-- Background backdrop, show/hide based on modal state. -->
<div
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
x-show="{{ model }}"
></div>
<div
class="fixed inset-0 z-50 w-screen overflow-y-auto"
x-show="{{ model }}"
>
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<!-- Modal panel, show/hide based on modal state. -->
<div
class="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg"
{% if close_on_click_outside %}
@click.away="{{ model }} = false"
{% endif %}
>
<div class="bg-white px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
{% slot "prepend" / %}
<div {% html_attrs content_attrs %}>
{% if component_vars.is_filled.title %}
<h3
:id="id"
{% html_attrs title_attrs class="font-semibold text-gray-900" %}
>
{% slot "title" / %}
</h3>
{% endif %}
{% slot "content" / %}
</div>
{% slot "append" / %}
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 gap-5">
{% if not confirm_hide %}
{% component "Button"
variant=confirm_variant
color=confirm_color
disabled=confirm_disabled
href=confirm_href
type=confirm_type
attrs=confirm_attrs
%}
{{ confirm_text }}
{% endcomponent %}
{% endif %}
{% if not cancel_hide %}
{% component "Button"
variant=cancel_variant
color=cancel_color
disabled=cancel_disabled
href=cancel_href
type=cancel_type
attrs=cancel_attrs
%}
{{ cancel_text }}
{% endcomponent %}
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
""" # noqa: E501
#####################################
# 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
@register("Tags")
class Tags(Component):
def get_context_data(
self,
/,
*,
tag_type: str,
js_props: dict,
editable: bool = True,
max_width: Union[int, str] = '300px',
attrs: Optional[dict] = None,
):
all_tags = TAG_TYPE_META[tag_type.upper()].allowed_values # type: ignore[index]
return {
"editable": editable,
"all_tags": all_tags,
"max_width": max_width,
"attrs": attrs,
"js_props": js_props,
}
template: types.django_html = """
<div
x-data="tags"
x-props="{
initAllTags: '{{ all_tags|json|escape }}',
initTags: {{ js_props.initTags|escape }},
onChange: {{ js_props.onChange|escape }},
}"
{% html_attrs attrs class="pt-3 flex flex-col gap-y-3 items-start" %}
>
<input x-ref="tagsInput" type="hidden" name="tags" value="" />
{% slot "title" %}
<p class="text-sm">
Tags:
</p>
{% endslot %}
<template x-for="(tag, index) in tags.value">
<div
class="tag text-sm flex flex-col gap-1 w-full"
style="max-width: {{ max_width }}"
>
<div class="flex gap-6 w-full justify-between items-center">
<select
name="_tags"
class="flex-auto py-1 px-2"
@change="(ev) => setTag(index, ev.target.value)"
{% if not editable %}
disabled
{% endif %}
>
<template x-for="option in tag.options">
<option
:value="option"
:selected="option === tag.value"
x-text="option"
>
</option>
</template>
</select>
{% if editable %}
<div>
{% component "Button"
color="error"
attrs:class="!py-1"
attrs:@click="removeTag(index)"
%}
Remove
{% endcomponent %}
</div>
{% endif %}
</div>
</div>
</template>
{% if editable %}
<div x-show="tags.value.length < allTags.value.length">
{% component "Button"
attrs:class="!py-1"
attrs:@click="addTag"
%}
Add tag
{% endcomponent %}
</div>
{% endif %}
</div>
"""
js: types.js = """
// Define component similarly to defining Vue components
const Tags = AlpineComposition.defineComponent({
name: "tags",
props: {
initAllTags: { type: String, required: true },
initTags: { type: Array, required: true },
},
emits: {
change: () => true,
},
// Instead of Alpine's init(), use setup()
// Props are passed down as reactive props, same as in Vue
// Second argument is the Alpine component instance.
setup(props, vm) {
const { ref, watch } = AlpineComposition.createReactivityAPI(vm);
const allTags = ref([]);
const tags = ref([]);
// Set the initial state from HTML
if (props.initAllTags) {
allTags.value = JSON.parse(props.initAllTags);
}
if (props.initTags) {
tags.value = props.initTags.map((t) => ({
value: t,
options: [],
}));
const availableTags = getAvailableTags();
tags.value = tags.value.map((t) => ({
value: t.value,
options: [t.value, ...availableTags],
}));
}
watch(tags, () => {
onTagsChange();
});
onTagsChange();
// Methods
const addTag = () => {
const availableTags = getAvailableTags();
if (!availableTags.length) return;
// Add tag by removing it from available tags
const nextValue = availableTags.shift();
const newSelectedTags = [
...tags.value.map((t) => t.value),
nextValue,
];
// And add it to the selected tags
tags.value = newSelectedTags.map((t) => ({
value: t,
options: [t, ...availableTags],
}));
}
const removeTag = (index) => {
// Remove the removed tag from selected items
tags.value = tags.value.filter((_, i) => i !== index);
// And add it to the available tags
const availableTags = getAvailableTags();
tags.value = tags.value.map((t) => ({
value: t.value,
options: [t.value, ...availableTags],
}));
}
const setTag = (index, value) => {
// Update the value
const oldValue = tags.value[index].value;
tags.value = tags.value.map((t) => ({
value: t.value === oldValue ? value : t.value,
options: t.options,
}));
// Then update the available tags
const availableTags = getAvailableTags();
tags.value = tags.value.map((t) => ({
value: t.value,
options: [t.value, ...availableTags],
}));
}
// When tags are added or removed, we add/remove HTML by AlpineJS,
// so user doesn't have to refresh the page.
function onTagsChange() {
if (vm.$refs.tagsInput) {
vm.$refs.tagsInput.value = tags.value.map((t) => t.value).join(',');
}
// Emit the final list of selected tags
const payload = tags.value.map((t) => t.value);
vm.$emit("change", payload);
}
function getAvailableTags() {
const selectedTagsSet = new Set(tags.value.map((t) => t.value));
return allTags.value.filter((t) => !selectedTagsSet.has(t));
}
return {
tags,
allTags,
addTag,
removeTag,
setTag,
};
},
});
document.addEventListener('alpine:init', () => {
AlpineComposition.registerComponent(Alpine, Tags);
});
"""
#####################################
# FORM
#####################################
@register("Form")
class Form(Component):
def get_context_data(
self,
/,
*,
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,
):
if type == "table":
form_content_tag = "table"
elif type == "paragraph":
form_content_tag = "div"
elif type == "ul":
form_content_tag = "ul"
else:
form_content_tag = "div"
# Add AlpineJS bindings to submit button
submit_attrs = {
**(submit_attrs or {}),
":disabled": "isSubmitting",
}
return {
"form_content_tag": form_content_tag,
"form_content_attrs": form_content_attrs,
"method": method,
"editable": editable,
"submit_hide": submit_hide,
"submit_text": submit_text,
"submit_href": submit_href,
"submit_disabled": submit_disabled or not editable,
"submit_variant": submit_variant,
"submit_color": submit_color,
"submit_type": submit_type,
"submit_attrs": submit_attrs,
"cancel_hide": cancel_hide,
"cancel_text": cancel_text,
"cancel_href": cancel_href,
"cancel_disabled": cancel_disabled,
"cancel_variant": cancel_variant,
"cancel_color": cancel_color,
"cancel_type": cancel_type,
"cancel_attrs": cancel_attrs,
"actions_hide": actions_hide,
"actions_attrs": actions_attrs,
"attrs": attrs,
}
template: types.django_html = """
<form
{% if submit_href and editable %} action="{{ submit_href }}" {% endif %}
method="{{ method }}"
x-data="form"
{% html_attrs attrs %}
>
<{{ form_content_tag }}
@click="updateFormModel"
@change="updateFormModel"
{% html_attrs form_content_attrs %}
>
{% slot "form" default / %}
</{{ form_content_tag }}>
{% slot "below_form" / %}
{% if not actions_hide %}
<div {% html_attrs actions_attrs class="pt-4" %}>
{% slot "actions_prepend" / %}
{% if not submit_hide %}
{% component "Button"
variant=submit_variant
color=submit_color
disabled=submit_disabled
type=submit_type
attrs=submit_attrs
%}
{{ submit_text }}
{% endcomponent %}
{% endif %}
{% if not cancel_hide %}
{% component "Button"
variant=cancel_variant
color=cancel_color
disabled=cancel_disabled
href=cancel_href
type=cancel_type
attrs=cancel_attrs
%}
{{ cancel_text }}
{% endcomponent %}
{% endif %}
{% slot "actions_append" / %}
</div>
{% endif %}
</form>
"""
js: types.js = """
document.addEventListener('alpine:init', () => {
Alpine.data('form', () => {
const data = Alpine.reactive({
// Variables
formData: {},
isSubmitting: false,
// Methods
updateFormModel(event) {
const form = this.$el.closest("form");
if (!form) {
this.formData = null;
return;
};
const formDataObj = new FormData(form)
this.formData = [...formDataObj.entries()].reduce((agg, [key, val]) => {
agg[key] = val;
return agg;
}, {});
},
onSubmit(event) {
if (this.isSubmitting) return;
this.isSubmitting = true;
event.target.submit();
},
});
// Detect when Alpine's form state has changed and emit event when that happens
// NOTE: Alpine's reactivity is based on @vue/reactivity
Alpine.watch(() => data.formData, (newVal, oldVal) => {
const hasDataChanged = JSON.stringify(newVal || null) !== JSON.stringify(oldVal || null);
if (!hasDataChanged) return;
data.$dispatch('change', newVal);
});
return data;
});
});
"""
#####################################
# 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 `<a>` tag pointing to this
link.
"""
item_attrs: Optional[dict] = None
"""HTML attributes specific to this item."""
@register("Breadcrumbs")
class Breadcrumbs(Component):
def get_context_data(
self,
/,
*,
items: List[Breadcrumb],
attrs: Optional[dict] = None,
):
return {
"items": items,
"attrs": attrs,
}
template: types.django_html = """
<nav
aria-label="Breadcrumb"
{% html_attrs attrs class="flex border-b border-gray-200 bg-white" %}
>
<ol
role="list"
class="mx-auto flex w-full max-w-screen-xl space-x-4 px-4 sm:px-6 lg:px-8"
>
{% for crumb in items %}
<li class="flex">
<div class="flex items-center">
{# Divider #}
{% if not forloop.first %}
<svg
class="h-full w-6 flex-shrink-0 text-gray-200"
viewBox="0 0 24 44"
preserveAspectRatio="none"
fill="currentColor"
aria-hidden="true"
>
<path d="M.293 0l22 22-22 22h1.414l22-22-22-22H.293z" />
</svg>
{% endif %}
{# Breadcrumb link #}
{% if crumb.link %}
<a
href="{{ crumb.link }}"
{% html_attrs
crumb.item_attrs
class="ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"
%}
>
{% else %}
<span
{% html_attrs
crumb.item_attrs
class="ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"
%}
>
{% endif %}
{{ crumb.value }}
{% if crumb.link %}
</a>
{% else %}
</span>
{% endif %}
</div>
</li>
{% endfor %}
</ol>
</nav>
"""
#####################################
# 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",
},
),
],
]
@register("Bookmarks")
class Bookmarks(Component):
def get_context_data(
self,
/,
*,
project_id: int,
bookmarks: List[ProjectBookmark],
attrs: Optional[dict] = None,
):
bookmark_data: List[BookmarkItem] = []
attachment_data: List[BookmarkItem] = []
for bookmark in 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/{project_id}/bookmark/{bookmark['id']}"
f"?{ProjectPageTabsToQueryParams.OUTPUTS.value}"
f"&panel={bookmark['attachment']['output']['id']}" # type: ignore[index]
) # type: ignore[index]
else:
edit_url = f"/edit/{project_id}/bookmark/{bookmark['id']}"
entry = BookmarkItem(
text=bookmark['text'],
url=bookmark['url'],
id=bookmark['id'],
edit_url=edit_url,
)
if is_attachment:
attachment_data.append(entry)
else:
bookmark_data.append(entry)
create_bookmark_url = f"/create/{project_id}/bookmark"
return {
"bookmark_data": bookmark_data,
"attachment_data": attachment_data,
"create_bookmark_url": create_bookmark_url,
"menu_items": menu_items,
"attrs": attrs,
"theme": theme,
}
template: types.django_html = """
<li x-data="bookmarks" {% html_attrs attrs class="pt-4" %}>
{% component "Icon"
name="bookmark"
variant="outline"
text_attrs:class="py-2 text-sm"
%}
Project Bookmarks
{% endcomponent %}
<ul class="mx-4">
{% for bookmark in bookmark_data %}
{% component "Bookmark"
bookmark=bookmark
js:onMenuToggle="onContextMenuToggle"
/ %}
{% endfor %}
<li>
{% component "Icon"
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"
%}
Add New Bookmark
{% endcomponent %}
</li>
<div class="border-b border-gray-200 my-2 pt-2 text-sm font-bold">
Attachments:
</div>
{% for bookmark in attachment_data %}
{% component "Bookmark"
bookmark=bookmark
js:onMenuToggle="onContextMenuToggle"
/ %}
{% endfor %}
</ul>
<template x-if="contextMenuItem.value">
<div class="self-center">
{% component "Menu"
items=menu_items
model="contextMenuItem.value"
anchor="contextMenuRef.value"
anchor_dir="bottom"
list_attrs:class="w-24 ml-8 z-40"
attrs:@click_outside="onContextMenuClickOutside"
/ %}
</div>
</template>
</li>
"""
js: types.js = """
const useContextMenu = (reactivity) => {
const { ref } = reactivity;
const contextMenuItem = ref(null);
const contextMenuRef = ref(null);
const contextMenuReset = () => {
contextMenuItem.value = null;
contextMenuRef.value = null;
};
const onContextMenuToggle = (data) => {
const { item, el } = data;
const willUntoggle = contextMenuItem.value && contextMenuItem.value.id === item.id;
// NOTE: We need to remove the component first before we can re-render it
// at a different place using `x-anchor`.
contextMenuItem.value = null;
contextMenuRef.value = null;
// If we are to untoggled currently-active menu, since we've already set values to null,
// there's nothing more to be done.
if (willUntoggle) {
return;
}
// Otherwise, we should open a new menu
setTimeout(() => {
contextMenuItem.value = item;
contextMenuRef.value = el;
});
};
const onContextMenuClickOutside = (event) => {
contextMenuReset();
};
return {
contextMenuItem,
contextMenuRef,
contextMenuReset,
onContextMenuToggle,
onContextMenuClickOutside,
};
};
// Define component similarly to defining Vue components
const Bookmarks = AlpineComposition.defineComponent({
name: "bookmarks",
props: {},
emits: {},
setup(props, vm, reactivity) {
const {
contextMenuItem,
contextMenuRef,
onContextMenuToggle,
onContextMenuClickOutside,
} = useContextMenu(reactivity);
return {
contextMenuItem,
contextMenuRef,
onContextMenuToggle,
onContextMenuClickOutside,
};
},
});
document.addEventListener('alpine:init', () => {
AlpineComposition.registerComponent(Alpine, Bookmarks);
});
"""
#####################################
# BOOKMARK
#####################################
class BookmarkItem(NamedTuple):
id: int
text: str
url: str
edit_url: str
@register("Bookmark")
class Bookmark(Component):
def get_context_data(
self,
/,
*,
bookmark: BookmarkItem,
js: Optional[dict] = None,
):
return {
"theme": theme,
"bookmark": bookmark._asdict(),
"js": js,
}
template: types.django_html = """
<li
x-data="bookmark"
x-props="{
onMenuToggle: {{ js.onMenuToggle|escape }},
bookmark: {{ bookmark|alpine }},
}"
class="list-disc ml-8"
>
<div class="flex">
<a
href="{{ bookmark.url }}"
target="_blank"
class="grow px-2 py-1 text-xs font-semibold {{ theme.sidebar_link }}"
>
{{ bookmark.text }}
</a>
{% component "Icon"
name='ellipsis-vertical'
variant='outline'
color=theme.sidebar_link
svg_attrs:class="inline"
text_attrs:class="p-0"
attrs:class="self-center cursor-pointer"
attrs:x-ref="bookmark_menu"
attrs:@click="onMenuToggle"
/ %}
</div>
</li>
"""
js: types.js = """
// Define component similarly to defining Vue components
const Bookmark = AlpineComposition.defineComponent({
name: "bookmark",
props: {
bookmark: { type: Object, required: true },
},
emits: {
menuToggle: (obj) => true,
},
setup(props, vm) {
const onMenuToggle = () => {
vm.$emit('menuToggle', { item: props.bookmark, el: vm.$refs.bookmark_menu });
}
return {
bookmark: props.bookmark,
onMenuToggle,
};
},
});
document.addEventListener('alpine:init', () => {
AlpineComposition.registerComponent(Alpine, Bookmark);
});
"""
#####################################
# 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 `<a>` 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."""
@register("List")
class ListComponent(Component):
def get_context_data(
self,
/,
*,
items: List[ListItem],
attrs: Optional[dict] = None,
item_attrs: Optional[dict] = None,
):
return {
"items": items,
"attrs": attrs,
"item_attrs": item_attrs,
}
template: types.django_html = """
<ul role="list" {% html_attrs attrs class="flex flex-col gap-4" %}>
{% for item in items %}
<li {% html_attrs item.attrs item_attrs class="group flex justify-between gap-x-6 border border-gray-300 pl-4 pr-6 bg-white" %}>
<div class="flex min-w-0 w-full gap-x-4">
<div class="min-w-0 flex-auto">
{% if item.link %}
<a href="{{ item.link }}">
{% endif %}
<p class="text-sm font-semibold leading-6 text-gray-900 hover:text-gray-500">
{{ item.value }}
</p>
{% if item.link %}
</a>
{% endif %}
</div>
</div>
</li>
{% empty %}
{% slot "empty" default / %}
{% endfor %}
</ul>
""" # noqa: E501
#####################################
# TABS
#####################################
class TabEntry(NamedTuple):
header: str
content: str
disabled: bool = False
class TabStaticEntry(NamedTuple):
header: str
href: str
content: Optional[str]
disabled: bool = False
@register("_tabs")
class _TabsImpl(Component):
def get_context_data(
self,
/,
*,
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,
):
return {
"attrs": attrs,
"tabs": tabs,
"header_attrs": header_attrs,
"content_attrs": content_attrs,
"tabs_data": {"name": name},
"theme": theme,
}
template: types.django_html = """
<div
x-data="tabs"
data-init="{{ tabs_data|json|escape }}"
{% html_attrs attrs class="flex flex-col" %}
>
<ul class="flex border-b text-sm">
{% for tab in tabs %}
{% if not tab.disabled %}
<li
@click="setOpenTab( {{ forloop.counter }} )"
:class="{
'border-b-2 {{ theme.tab_active }}': openTab === {{ forloop.counter }}
}"
{% html_attrs header_attrs %}
>
<a
href="#"
:class="openTab === {{ forloop.counter }} ? '{{ theme.tab_text_active }}' : '{{ theme.tab_text_inactive }}'"
class="bg-white inline-block py-2 px-4 font-semibold transition"
>
{{ tab.header }}
</a>
</li>
{% else %}
<li class="mr-1">
<p class="text-gray-300 bg-white inline-block py-2 px-4 font-semibold">
{{ tab.header }}
</p>
</li>
{% endif %}
{% endfor %}
</ul>
<div class="w-full h-full flex-grow-1 relative overflow-y-scroll" x-ref="container">
<article class="px-4 pt-5 absolute w-full h-full">
{% for tab in tabs %}
<div
x-show="openTab === {{ forloop.counter }}"
{% html_attrs content_attrs %}
>
{{ tab.content }}
</div>
{% endfor %}
</article>
</div>
</div>
""" # noqa: E501
js: types.js = """
document.addEventListener("alpine:init", () => {
Alpine.data("tabs", () => ({
// Variables
openTab: 1,
name: null,
// Computed
get tabQueryName() {
return `tabs-${this.name}`;
},
// Methods
init() {
// If we provided the `name` argument to the "tabs" component, then
// we register a listener for the query param `tabs-{name}`.
// The value of this query param is the current active tab (index).
//
// When user changes the currently-open tab, we push that info to the URL
// by updating the `tabs-{name}` query param.
//
// And when we navigate to a URL that already had `tabs-{name}` query param
// set, we load that tab.
if (this.$el.dataset['init']) {
const { name } = JSON.parse(this.$el.dataset['init']);
if (name) {
this.name = name
app.query.registerParam(
this.tabQueryName,
(newVal, oldVal) => this.onTabQueryParamChange(newVal, oldVal),
);
}
}
// Sometimes, the scrollable tab content area is scrolled to the bottom
// when the page loads. So we ensure here that the we scroll to the top if not already
// Also see https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollTop
const containerEl = this.$refs.container;
if (containerEl.scrollTop) {
this.$refs.container.scrollTop = 0;
}
},
/**
* Set the current open tab and push the info to query params.
*
* @param {number} tabIndex
*/
setOpenTab(tabIndex) {
this.openTab = tabIndex;
if (this.name) {
app.query.setParams({ [this.tabQueryName]: tabIndex });
}
},
/**
* Handle tab change from URL
*
* @param {*} newValue
* @param {*} oldValue
*/
onTabQueryParamChange(newValue, oldValue) {
if (newValue == null) return;
const newValNum = typeof newValue === "number" ? newValue : Number.parseInt(newValue);
if (newValNum === this.openTab) return;
this.setOpenTab(newValNum);
},
}));
});
"""
# 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.
@register("Tabs")
class Tabs(Component):
def get_context_data(
self,
/,
*,
# 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,
):
return {
"tabs": [],
"name": name,
"attrs": attrs,
"header_attrs": header_attrs,
"content_attrs": content_attrs,
"tabs_data": {"name": name},
}
def on_render_after(self, context, template, rendered) -> str:
# By the time we get here, all child TabItem components should have been
# rendered, and they should've populated the tabs list.
tabs: List[TabEntry] = context["tabs"]
return _TabsImpl.render(
kwargs={
"tabs": tabs,
"name": context["name"],
"attrs": context["attrs"],
"header_attrs": context["header_attrs"],
"content_attrs": context["content_attrs"],
},
deps_strategy="ignore",
)
template: types.django_html = """
{% provide "_tab" tabs=tabs enabled=True %}
{% slot "content" default / %}
{% endprovide %}
"""
# Use this component to define individual tabs inside the default slot
# inside the `tab` component.
@register("TabItem")
class TabItem(Component):
def get_context_data(
self,
/,
*,
header: str,
disabled: bool = False,
):
# Access the list of tabs registered for parent Tabs component
# This raises if we're not nested inside the Tabs component.
tab_ctx = self.inject("_tab")
# We accessed the _tab context, but we're inside ANOTHER TabItem
if not tab_ctx.enabled:
raise RuntimeError(
f"Component '{self.name}' was called with no parent Tabs component. "
f"Either wrap '{self.name}' in Tabs component, or check if the component "
f"is not a descendant of another instance of '{self.name}'"
)
parent_tabs = tab_ctx.tabs
return {
"empty_tabs": [],
"parent_tabs": parent_tabs,
"header": header,
"disabled": disabled,
}
def on_render_after(self, context, template, content) -> None:
parent_tabs: List[dict] = context["parent_tabs"]
parent_tabs.append({
"header": context["header"],
"disabled": context["disabled"],
"content": mark_safe(content.strip()),
})
template: types.django_html = """
{% provide "_tab" tabs=empty_tabs enabled=False %}
{% slot "content" default / %}
{% endprovide %}
"""
@register("TabsStatic")
class TabsStatic(Component):
def get_context_data(
self,
/,
*,
tabs: List[TabStaticEntry],
index: int = 0,
hide_body: bool = False,
attrs: Optional[dict] = None,
header_attrs: Optional[dict] = None,
content_attrs: Optional[dict] = None,
):
selected_content = tabs[index].content
tabs_data = []
for tab_index, tab in enumerate(tabs):
is_selectd = tab_index == 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))
return {
"attrs": attrs,
"tabs_data": tabs_data,
"header_attrs": header_attrs,
"content_attrs": content_attrs,
"hide_body": hide_body,
"selected_content": selected_content,
"theme": theme,
}
template: types.django_html = """
<div {% html_attrs attrs class="flex flex-col" %}>
<ul class="flex border-b mb-5 bg-white">
{% for tab, styling in tabs_data %}
{% if not tab.disabled %}
<li {% html_attrs header_attrs class="border-b-2" class=styling.tab %}>
<a
href="{{ tab.href }}"
{% html_attrs
header_attrs
class="bg-white inline-block py-2 px-4 font-semibold transition"
class=styling.text
%}
>
{{ tab.header }}
</a>
</li>
{% else %}
<li class="mr-1">
<p class="text-gray-300 bg-white inline-block py-2 px-4 font-semibold">
{{ tab.header }}
</p>
</li>
{% endif %}
{% endfor %}
</ul>
{% if not hide_body %}
<div class="w-full h-full flex-grow-1 relative overflow-y-scroll">
<article class="px-4 pt-5 absolute w-full h-full">
<div {% html_attrs content_attrs %}>
{{ selected_content }}
</div>
</article>
</div>
{% endif %}
</div>
"""
#####################################
# PROJECT_INFO
#####################################
class ProjectInfoEntry(NamedTuple):
title: str
value: str
@register("ProjectInfo")
class ProjectInfo(Component):
def get_context_data(
self,
/,
*,
project: Project,
project_tags: List[str],
contacts: List[ProjectContact],
status_updates: List[ProjectStatusUpdate],
roles_with_users: List[ProjectRole],
editable: bool,
):
project_edit_url = f"/edit/{project['id']}/"
edit_project_roles_url = f"/edit/{project['id']}/roles/"
edit_contacts_url = f"/edit/{project['id']}/contacts/"
create_status_update_url = f"/create/{project['id']}/status_update/"
contacts_data = [
{
"contact": contact,
"link_url": f"/contacts/{contact['link_id']}",
}
for contact in contacts
]
project_info = [
ProjectInfoEntry("Org", project['organization']['name']),
ProjectInfoEntry("Duration", f"{project['start_date']} - {project['end_date']}"),
ProjectInfoEntry("Status", project['status']),
ProjectInfoEntry("Tags", ", ".join(project_tags) or "-"),
]
return {
"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": project,
"roles_with_users": roles_with_users,
"project_info": project_info,
"status_updates": status_updates,
"editable": editable,
}
template: types.django_html = """
<div class="prose flex flex-col gap-8">
{# Info section #}
<div class="border-b border-neutral-300">
<div class="flex justify-between items-start">
<h3 class="mt-0">Project Info</h3>
{% if editable %}
{% component "Button"
href=project_edit_url
attrs:class="not-prose"
%}
Edit Project
{% endcomponent %}
{% endif %}
</div>
<table>
{% for key, value in project_info %}
<tr>
<td class="font-bold pr-4">
{{ key }}:
</td>
<td>
{{ value }}
</td>
</tr>
{% endfor %}
</table>
</div>
{# Status Updates section #}
{% component "ProjectStatusUpdates"
project_id=project.id
status_updates=status_updates
editable=editable
/ %}
<div class="xl:grid xl:grid-cols-2 gap-10">
{# Team section #}
<div class="border-b border-neutral-300">
<div class="flex justify-between items-start">
<h3 class="mt-0">Team</h3>
{% if editable %}
{% component "Button"
href=edit_project_roles_url
attrs:class="not-prose"
%}
Edit Team
{% endcomponent %}
{% endif %}
</div>
{% component "ProjectUsers"
project_id=project.id
roles_with_users=roles_with_users
available_roles=None
available_users=None
editable=False
/ %}
</div>
{# Contacts section #}
<div>
<div class="flex justify-between items-start max-xl:mt-6">
<h3 class="mt-0">Contacts</h3>
{% if editable %}
{% component "Button"
href=edit_contacts_url
attrs:class="not-prose"
%}
Edit Contacts
{% endcomponent %}
{% endif %}
</div>
{% if contacts_data %}
<table>
<tr>
<th>Name</th>
<th>Job</th>
<th>Link</th>
</tr>
{% for row in contacts_data %}
<tr>
<td>{{ row.contact.name }}</td>
<td>{{ row.contact.job }}</td>
<td>
{% component "Icon"
href=row.link_url
name="arrow-top-right-on-square"
variant="outline"
color="text-gray-400 hover:text-gray-500"
/ %}
</td>
</tr>
{% endfor %}
</table>
{% else %}
<p class="text-sm italic">No entries</p>
{% endif %}
</div>
</div>
</div>
"""
#####################################
# PROJECT_NOTES
#####################################
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']
)
return {
"timestamp": formatted_modified_by,
"notes": comment['text'],
"edit_href": f"/update/{note['project']['id']}/note/{note['id']}/comment/{comment['id']}/",
}
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]
notes_data.append(
{
"text": note['text'],
"timestamp": note['created'],
"edit_href": f"/edit/{note['project']['id']}/note/{note['id']}/",
"comments": comments_data,
"create_comment_url": f"/create/{note['project']['id']}/note/{note['id']}/",
}
)
return notes_data
@register("ProjectNotes")
class ProjectNotes(Component):
def get_context_data(
self,
/,
*,
project_id: int,
notes: List[ProjectNote],
comments_by_notes: Dict[int, List[ProjectNoteComment]],
editable: bool,
):
create_note_url = f"/create/{project_id}/note/"
notes_data = _make_notes_data(notes, comments_by_notes)
return {
"create_note_url": create_note_url,
"notes_data": notes_data,
"editable": editable,
}
template: types.django_html = """
<div class="prose">
<h3>Notes</h3>
{% if notes_data %}
<div class="mt-8">
{% for note in notes_data %}
<div class="py-2" style="border-top: solid 1px lightgrey">
<div class="flex justify-between gap-4 pt-2">
<span class="prose-sm prose-figure">
{{ note.timestamp }}
</span>
{% if editable %}
{% component "Icon"
name="pencil-square"
variant="outline"
href=note.edit_href
color="text-gray-400 hover:text-gray-500"
/ %}
{% endif %}
</div>
<p class="my-0 text-gray-900">
{{ note.text }}
</p>
<details class="px-8 py-2">
<summary class="font-medium">
Comments
</summary>
{% for comment in note.comments %}
<div class="pl-8 pb-2" style="border-top: solid 1px grey;">
<div class="flex justify-between gap-4 pt-2">
<span class="prose-sm prose-figure">
{{ comment.timestamp }}
</span>
{% if editable %}
{% component "Icon"
name="pencil-square"
variant="outline"
href=comment.edit_href
color="text-gray-400 hover:text-gray-500"
/ %}
{% endif %}
</div>
<div class="flex-auto">
<p class="my-0">
{{ comment.text }}
</p>
</div>
</div>
{% endfor %}
<div class="text-right">
{% if editable %}
{% component "Button" href=note.create_comment_url %}
Add comment
{% endcomponent %}
{% endif %}
</div>
</details>
</div>
{% endfor %}
</div>
{% endif %}
{% if editable %}
{% component "Button" href=create_note_url %}
Add Note
{% endcomponent %}
{% endif %}
</div>
"""
#####################################
# 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]
@register("ProjectOutputsSummary")
class ProjectOutputsSummary(Component):
def get_context_data(
self,
/,
*,
project_id: int,
outputs: List["OutputWithAttachmentsAndDeps"],
editable: bool,
phase_titles: Dict[ProjectPhaseType, str],
):
outputs_by_phase = group_by(outputs, lambda output, _: output[0]['phase']['phase_template']['type'])
groups: List[dict] = []
for phase_meta in PROJECT_PHASES_META.values():
phase_outputs = outputs_by_phase.get(phase_meta.type, [])
title = phase_titles[phase_meta.type]
groups.append(
{
"phase_title": title,
"phase_type": phase_meta.type,
"outputs": phase_outputs,
"has_outputs": bool(phase_outputs),
}
)
return {
"project_id": project_id,
"editable": editable,
"groups": groups,
}
template: types.django_html = """
<div class="flex flex-col gap-y-3">
{% for group in groups %}
{% component "ExpansionPanel"
open=group.has_outputs
header_attrs:class="flex gap-x-2 prose"
%}
{% fill "header" %}
<h3 class="m-0">
{{ group.phase_title }}
</h3>
{% endfill %}
{% fill "content" %}
{% if group.outputs %}
{% component "ProjectOutputs"
outputs=group.outputs
project_id=project_id
phase_type=group.phase_type
editable=editable
/ %}
{% else %}
No outputs
{% endif %}
{% endfill %}
{% endcomponent %}
{% endfor %}
</div>
"""
#####################################
# PROJECT_STATUS_UPDATES
#####################################
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']}",
}
@register("ProjectStatusUpdates")
class ProjectStatusUpdates(Component):
def get_context_data(
self,
/,
*,
project_id: int,
status_updates: List[ProjectStatusUpdate],
editable: bool,
):
create_status_update_url = f"/create/{project_id}/status_update"
updates_data = [_make_status_update_data(status_update) for status_update in status_updates]
return {
"create_status_update_url": create_status_update_url,
"updates_data": updates_data,
"editable": editable,
}
template: types.django_html = """
<div class="prose border-b border-neutral-300 pb-8">
<div class="flex justify-between items-start mb-4">
<h3 class="mt-0">Status Updates</h3>
{% if editable %}
{% component "Button" href=create_status_update_url %}
Add status update
{% endcomponent %}
{% endif %}
</div>
{% if updates_data %}
<div class="mt-8">
{% for update in updates_data %}
<div class="px-3 py-2" style="border-top: solid 1px lightgrey">
<div class="flex justify-between gap-4 pt-2">
<span class="prose-sm prose-figure">
{{ update.timestamp }}
</span>
{% if editable %}
{% component "Icon"
name="pencil-square"
variant="outline"
href=update.edit_href
color="text-gray-400 hover:text-gray-500"
/ %}
{% endif %}
</div>
<p class="my-0 text-gray-900">
{{ update.text }}
</p>
</div>
{% endfor %}
</div>
{% endif %}
</div>
"""
#####################################
# 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
@register("ProjectUsers")
class ProjectUsers(Component):
def get_context_data(
self,
/,
*,
project_id: int,
roles_with_users: List[ProjectRole],
available_roles: Optional[List[str]],
available_users: Optional[List[User]],
editable: bool = False,
):
roles_table_rows = []
for role in roles_with_users:
user = role['user']
if editable:
delete_action = ProjectUserAction.render(
kwargs={
"user_name": user['name'],
"project_id": project_id,
"role_id": role['id'],
},
deps_strategy="ignore",
)
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/{project_id}/role/create"
project_url = f"/project/{project_id}"
if available_roles:
available_role_choices = [
(role, role) for role in available_roles
]
else:
available_role_choices = []
if available_users:
available_user_choices = [
(str(user['id']), user['name']) for user in available_users
]
else:
available_user_choices = []
return {
"editable": editable,
"table_headers": roles_table_headers,
"table_rows": roles_table_rows,
"add_user_form": ProjectAddUserForm(
editable,
available_role_choices,
available_user_choices,
),
"submit_url": submit_url,
"project_url": project_url,
}
template: types.django_html = """
<div x-data="project_users">
{% if table_rows %}
{% component "Table"
headers=table_headers
rows=table_rows
attrs:@user_delete="onUserDelete"
/ %}
{% endif %}
{% if editable %}
<h4>Set project roles</h4>
<form
hx-post="{{ submit_url }}"
hx-swap="outerHTML"
method="post"
>
<table>
{{ add_user_form.as_table }}
</table>
{% component "Button" type="submit" %}
Set role
{% endcomponent %}
{% component "Button" variant="secondary" href=project_url %}
Go back
{% endcomponent %}
</form>
<template x-if="role && isDeleteDialogOpen">
{% component "Dialog"
model="isDeleteDialogOpen"
confirm_text="Delete"
confirm_href='#'
confirm_color="error"
confirm_attrs::href="role.delete_url"
content_attrs:class="w-full"
%}
{% fill "title" %}
<div class="flex">
<span>
Remove
<span x-text="role && role.user_name"></span>
from this project?
</span>
{% component "Icon"
name="trash"
variant="outline"
size=18
attrs:class="p-2 self-center"
/ %}
</div>
{% endfill %}
{% fill "content" %}
<div>
This action cannot be undone.
</div>
{% endfill %}
{% endcomponent %}
</template>
{% endif %}
</div>
"""
js: types.js = """
document.addEventListener('alpine:init', () => {
Alpine.data('project_users', () => ({
// Variables
isDeleteDialogOpen: false,
role: null,
// Methods
onUserDelete(event) {
const { role } = event.detail;
this.role = role;
this.isDeleteDialogOpen = !!role;
},
}));
});
"""
#####################################
# PROJECT_USER_ACTION
#####################################
@register("ProjectUserAction")
class ProjectUserAction(Component):
def get_context_data(
self,
/,
*,
project_id: int,
role_id: int,
user_name: str,
):
delete_url = f"/delete/{project_id}/{role_id}"
role_data = {
"delete_url": delete_url,
"role_id": role_id,
"user_name": user_name,
}
return {
"role": role_data,
}
template: types.django_html = """
<div x-data="{
role: {{ role | alpine }},
}">
{% component "Icon"
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"
attrs:@click.stop="$dispatch('user_delete', { role })"
/ %}
</div>
"""
#####################################
# PROJECT_OUTPUTS
#####################################
@register("ProjectOutputs")
class ProjectOutputs(Component):
def get_context_data(
self,
/,
*,
project_id: int,
phase_type: str,
outputs: List[OutputWithAttachmentsAndDeps],
editable: bool,
) -> Any:
outputs_data: List[RenderedProjectOutput] = []
for output_tuple in 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/{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]
)
outputs_data.append(
RenderedProjectOutput(
output=output,
dependencies=deps,
has_missing_deps=has_missing_deps,
output_data={
"editable": editable,
},
attachments=attach_data,
update_output_url=update_output_url,
)
)
return {
"outputs_data": outputs_data,
"editable": editable,
}
template: types.django_html = """
<div class="flex flex-col">
{% for data in outputs_data %}
<div class="flex gap-x-3">
<div>
{% component "ProjectOutputBadge"
completed=data.output.completed
missing_deps=data.has_missing_deps
/ %}
</div>
<div class="w-full">
{% component "ExpansionPanel"
panel_id=data.output.id
icon_position="right"
attrs:class="border-b border-solid border-gray-300 pb-2 mb-3"
header_attrs:class="flex align-center justify-between"
%}
{% fill "header" %}
<div>
{{ data.output.name }}
</div>
{% endfill %}
{% fill "content" %}
<div>
{# Dependencies #}
{% for dep in data.dependencies %}
{% component "ProjectOutputDependency" dependency=dep / %}
{% endfor %}
{# Own data + attachments #}
{% component "ProjectOutputForm" data=data editable=editable / %}
</div>
{% endfill %}
{% endcomponent %}
</div>
</div>
{% endfor %}
</div>
"""
#####################################
# PROJECT_OUTPUT_BADGE
#####################################
@register("ProjectOutputBadge")
class ProjectOutputBadge(Component):
def get_context_data(
self,
/,
*,
completed: bool,
missing_deps: bool,
):
return {
"completed": completed,
"missing_deps": missing_deps,
"theme": theme,
}
template: types.django_html = """
<span class="flex h-9 items-center">
{# Missing dependencies #}
{% if missing_deps %}
{% component "Icon"
name="exclamation-triangle"
variant="outline"
color="text-black"
size=32
stroke_width=2
attrs:title="A dependent dependency has not been met!"
/ %}
{# Completed #}
{% elif completed %}
<span class="relative z-10 flex h-8 w-8 items-center justify-center rounded-full {{ theme.check_interactive }}">
{% component "Icon"
name="check"
variant="outline"
color="text-white"
size=20
stroke_width=2
attrs:class="p-2"
/ %}
</span>
{# NOT completed #}
{% else %}
<span class="flex h-9 items-center" aria-hidden="true">
<span class="relative z-10 flex h-8 w-8 items-center justify-center rounded-full border-2 border-gray-300 bg-white">
</span>
</span>
{% endif %}
</span>
""" # noqa: E501
#####################################
# PROJECT_OUTPUT_DEPENDENCY
#####################################
@register("ProjectOutputDependency")
class ProjectOutputDependency(Component):
def get_context_data(self, /, *, dependency: "RenderedOutputDep"):
return {
"attachments": dependency.attachments,
"dependency": dependency.dependency,
"phase_url": dependency.phase_url,
"OUTPUT_DESCRIPTION_PLACEHOLDER": OUTPUT_DESCRIPTION_PLACEHOLDER,
}
template: types.django_html = """
<div
class="pb-3 mb-3 border-b border-solid border-gray-300"
x-data="project_output_dependency"
x-props="{
initAttachments: '{{ attachments|json|escape }}'
}"
>
<div class="w-full bg-gray-100 text-sm p-2" style="min-height: 100px;">
{% if dependency.output.completed %}
{% if dependency.output.description %}
{{ dependency.output.description }}
{% else %}
<span class="italic text-gray-500">
{{ OUTPUT_DESCRIPTION_PLACEHOLDER }}
</span>
{% endif %}
{% else %}
<span class="text-gray-500 italic">
{% component "Icon"
name="exclamation-triangle"
variant="outline"
size=24
stroke_width=2
color="text-gray-500"
attrs:class="float-left pr-1"
/ %}
Missing '{{ dependency.output.name }}' from
{% component "Button"
variant="plain"
href=phase_url
attrs:target="_blank"
attrs:class="hover:text-gray-600 !underline"
%}
{{ dependency.output.phase.phase_template.type|title }}
{% endcomponent %}
</span>
{% endif %}
</div>
{# Attachments of parent dependencies #}
{% component "ProjectOutputAttachments"
editable=False
has_attachments=dependency.attachments
js_props:attachments="attachments.value"
/ %}
</div>
"""
js: types.js = """
// Define component similarly to defining Vue components
const ProjectOutputDependency = AlpineComposition.defineComponent({
name: 'project_output_dependency',
props: {
initAttachments: { type: String, required: true },
},
// Instead of Alpine's init(), use setup()
// Props are passed down as reactive props, same as in Vue
// Second argument is the Alpine component instance.
setup(props, vm, { ref }) {
const attachments = ref([]);
// Set the initial state from HTML
if (props.initAttachments) {
attachments.value = JSON.parse(props.initAttachments).map(({ url, text, tags }) => ({
url,
text,
tags,
isPreview: true,
}));
}
// Only those variables exposed by returning can be accessed from within HTML
return {
attachments,
};
},
});
document.addEventListener('alpine:init', () => {
AlpineComposition.registerComponent(Alpine, ProjectOutputDependency);
});
"""
#####################################
# PROJECT_OUTPUT_ATTACHMENTS
#####################################
class ProjectOutputAttachmentsJsProps(TypedDict):
attachments: str
@register("ProjectOutputAttachments")
class ProjectOutputAttachments(Component):
def get_context_data(
self,
/,
*,
has_attachments: bool,
js_props: ProjectOutputAttachmentsJsProps,
editable: bool,
attrs: Optional[dict] = None,
):
return {
"has_attachments": has_attachments,
"editable": editable,
"attrs": attrs,
"js_props": js_props,
"text_max_len": FORM_SHORT_TEXT_MAX_LEN,
"tag_type": "project_output_attachment",
}
template: types.django_html = """
<div
x-data="project_output_attachments"
x-props="{
...{{ js_props|js }},
}"
{% html_attrs attrs class="pt-3 flex flex-col gap-y-3 items-start" %}
>
<div>
{% 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 %}
</div>
<template x-for="(attachment, index) in attachments.value">
<div class="project-output-form-attachment w-full">
<div class="text-sm flex gap-3 w-full justify-between">
{# Attachment preview #}
<div x-show="attachment.isPreview">
{% component "Button"
variant="plain"
link=True
attrs:x-bind:href="attachment.url"
attrs:x-text="attachment.text"
attrs:target="_blank"
attrs:class="hover:text-gray-600 !underline"
attrs:style="color: cornflowerblue;"
/ %}
</div>
{# Attachment form #}
<div x-show="!attachment.isPreview" class="flex flex-col gap-1">
<label for="id_text">Text:</label>
<input
type="text"
name="text"
id="id_text"
maxlength="{{ text_max_len }}"
required
{% if not editable %} disabled {% endif %}
class="text-sm py-1 px-2"
:value="attachment.text"
@change="(ev) => $emit('updateAttachmentData', index, { text: ev.target.value })"
>
<label for="id_url">Url:</label>
<input
type="url"
name="url"
id="id_url"
required
{% if not editable %} disabled {% endif %}
class="text-sm py-1 px-2"
:value="attachment.url"
@change="(ev) => $emit('updateAttachmentData', index, { url: ev.target.value })"
>
</div>
{% if editable %}
<div class="flex gap-2 flex-wrap justify-end">
<div>
{% component "Button"
attrs:class="!py-1"
attrs:x-text="attachment.isPreview ? 'Edit' : 'Preview'"
attrs:@click="() => $emit('toggleAttachment', index)"
%}
Edit
{% endcomponent %}
</div>
<div>
{% component "Button"
color="error"
attrs:class="!py-1"
attrs:@click="() => $emit('removeAttachment', index)"
%}
Remove
{% endcomponent %}
</div>
</div>
{% endif %}
</div>
{% component "Tags"
tag_type=tag_type
editable=editable
js_props:initTags="attachment.tags"
js_props:onChange="(tags) => $emit('setAttachmentTags', index, tags)"
attrs:class="pb-8"
/ %}
</div>
</template>
</div>
"""
js: types.js = """
const ProjectOutputAttachments = AlpineComposition.defineComponent({
name: "project_output_attachments",
props: {
attachments: { type: Object, required: true },
},
emits: {
updateAttachmentData: (index, data) => true,
setAttachmentTags: (index, tags) => true,
removeAttachment: (index) => true,
toggleAttachment: (index) => true,
},
setup(props, vm, { toRefs, watch }) {
const { attachments } = toRefs(props);
return {
attachments,
};
},
});
document.addEventListener("alpine:init", () => {
AlpineComposition.registerComponent(Alpine, ProjectOutputAttachments);
});
"""
#####################################
# 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
@register("ProjectOutputForm")
class ProjectOutputForm(Component):
def get_context_data(
self,
/,
*,
data: RenderedProjectOutput,
editable: bool,
):
return {
"data": data,
"editable": editable,
"alpine_attachments": [d._asdict() for d in data.attachments],
"OUTPUT_DESCRIPTION_PLACEHOLDER": OUTPUT_DESCRIPTION_PLACEHOLDER,
}
template: types.django_html = """
<div
x-data="project_output_form"
x-props="{
initAttachments: '{{ alpine_attachments|json|escape }}'
}"
>
{% component "Form"
submit_href=data.update_output_url
actions_hide=True
%}
{# Output description - editable #}
{% if editable %}
<textarea
name="description"
class="w-full text-sm p-2 mb-2"
placeholder="{{ OUTPUT_DESCRIPTION_PLACEHOLDER }}"
style="min-height: 100px;"
>{{ data.output.description }}</textarea>
{% else %}
{# Output description - readonly #}
<div
class="w-full bg-gray-100 italic text-gray-500 text-sm p-2 mb-2"
style="min-height: 100px;"
>
{% if data.output.description %}
{{ data.output.description }}
{% else %}
{{ OUTPUT_DESCRIPTION_PLACEHOLDER }}
{% endif %}
</div>
{% endif %}
<div class="flex flex-wrap justify-between items-center gap-y-3">
<div class="flex items-center gap-x-2">
Completed:
{# NOTE: See https://stackoverflow.com/a/1992745/9788634 #}
<input type='hidden' value='0' name='completed'
{% if not editable %} disabled {% endif %}
>
<input type="checkbox"
name="completed"
style="height: 20px; width: 20px"
{% if data.output.completed %} checked {% endif %}
{% if not editable %} disabled {% endif %}
/>
</div>
<div class="flex gap-x-2 ml-auto items-center justify-between {% if editable %} basis-52 {% endif %}">
{% if editable %}
{% component "Button"
variant="secondary"
attrs:@click="addAttachment"
%}
Add attachment
{% endcomponent %}
{% component "Button"
attrs:@click="onOutputSubmit({ reload: true })"
%}
Save
{% endcomponent %}
{% endif %}
</div>
</div>
{% component "ProjectOutputAttachments"
has_attachments=data.attachments
editable=editable
js_props:attachments="attachments.value"
js_props:onToggleAttachment="(index) => toggleAttachmentPreview(index)"
js_props:onSetAttachmentTags="(index, tags) => setAttachmentTags(index, tags)"
js_props:onUpdateAttachmentData="(index, data) => updateAttachmentData(index, data)"
js_props:onRemoveAttachment="(index) => removeAttachment(index)"
/ %}
{% endcomponent %}
</div>
""" # noqa: E501
js: types.js = """
// Define component similarly to defining Vue components
const ProjectOutputForm = AlpineComposition.defineComponent({
name: 'project_output_form',
props: {
initAttachments: { type: String, required: true },
},
// Instead of Alpine's init(), use setup()
// Props are passed down as reactive props, same as in Vue
// Second argument is the Alpine component instance.
setup(props, vm, { ref, nextTick, watch }) {
const attachments = ref([]);
// Set the initial state
if (props.initAttachments) {
attachments.value = JSON.parse(props.initAttachments).map(({ url, text, tags }) => ({
url,
text,
isPreview: true,
tags,
}));
}
watch(attachments, () => {
onAttachmentsChange();
}, { immediate: true });
// Methods
const addAttachment = () => {
attachments.value = [...attachments.value, { url: "", text: "", tags: [], isPreview: false }];
};
const removeAttachment = (index) => {
attachments.value = attachments.value.filter((_, i) => i !== index);
// NOTE: For unknown reason, AlpineJS removes the attachment from for-loop
// only on second click. So we do so ourselves
const attachmentEls = [...vm.$el.querySelectorAll('.project-output-form-attachment')];
if (attachmentEls.length > attachments.value.length) {
attachmentEls[index].remove();
}
// Send the request to remove the attachment in the server too, but
// don't yet reload the page in case user is editing other attachments.
onOutputSubmit({ reload: false });
};
const setAttachmentTags = (index, tags) => {
attachments.value = attachments.value.map((attach, currIndex) => {
if (index !== currIndex) return attach;
return { ...attach, tags };
});
};
const updateAttachmentData = (index, data) => {
attachments.value = attachments.value.map((attach, currIndex) => {
if (index !== currIndex) return attach;
return { ...attach, ...data };
});
};
const toggleAttachmentPreview = (index) => {
let didCloseEditing = false;
attachments.value = attachments.value.map((attach, i) => {
if (index === i) {
attach.isPreview = !attach.isPreview;
if (attach.isPreview) didCloseEditing = true;
}
return attach;
});
if (didCloseEditing) onOutputSubmit({ reload: false });
};
// When attachments are added or removed, we add/remove HTML by AlpineJS,
// so user doesn't have to refresh the page.
function onAttachmentsChange() {
// We wait until the HTML is updated...
nextTick(() => {
// ...Then populate the generated HTML
const attachmentEls = [...vm.$el.querySelectorAll('.project-output-form-attachment')];
attachmentEls.forEach((attachEl, index) => {
if (index >= attachments.value.length) return;
const attachment = attachments.value[index];
attachEl.querySelector('input[name="url"]').value = attachment.url;
attachEl.querySelector('input[name="text"]').value = attachment.text;
});
});
}
const onOutputSubmit = ({ reload }) => {
/** @type {HTMLFormElement} */
const formEl = vm.$el.querySelector('form');
const formData = Object.fromEntries(new FormData(formEl));
const data = {
description: formData.description,
completed: formData.completed.toLowerCase() === "on",
attachments: attachments.value.map(({ text, url, tags }) => ({ text, url, tags })),
};
axios.post(formEl.action, data, {
method: formEl.method,
})
.then((response) => {
if (reload) location.reload();
})
.catch((error) => {
console.error(error);
});
};
return {
attachments,
addAttachment,
removeAttachment,
setAttachmentTags,
updateAttachmentData,
toggleAttachmentPreview,
onOutputSubmit,
};
},
});
document.addEventListener('alpine:init', () => {
AlpineComposition.registerComponent(Alpine, ProjectOutputForm);
});
"""
#####################################
#
# 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):
registry.register("Button", Button)
registry.register("Menu", Menu)
registry.register("MenuList", MenuList)
registry.register("Table", Table)
registry.register("Icon", Icon)
registry.register("heroicons", HeroIcon)
registry.register("ExpansionPanel", ExpansionPanel)
registry.register("ProjectPage", ProjectPage)
registry.register("ProjectLayoutTabbed", ProjectLayoutTabbed)
registry.register("Layout", Layout)
registry.register("RenderContextProvider", RenderContextProvider)
registry.register("Base", Base)
registry.register("Sidebar", Sidebar)
registry.register("Navbar", Navbar)
registry.register("Dialog", Dialog)
registry.register("Tags", Tags)
registry.register("Form", Form)
registry.register("Breadcrumbs", Breadcrumbs)
registry.register("Bookmarks", Bookmarks)
registry.register("Bookmark", Bookmark)
registry.register("List", ListComponent)
registry.register("_tabs", _TabsImpl)
registry.register("Tabs", Tabs)
registry.register("TabItem", TabItem)
registry.register("TabsStatic", TabsStatic)
registry.register("ProjectInfo", ProjectInfo)
registry.register("ProjectNotes", ProjectNotes)
registry.register("ProjectOutputsSummary", ProjectOutputsSummary)
registry.register("ProjectStatusUpdates", ProjectStatusUpdates)
registry.register("ProjectUsers", ProjectUsers)
registry.register("ProjectUserAction", ProjectUserAction)
registry.register("ProjectOutputs", ProjectOutputs)
registry.register("ProjectOutputBadge", ProjectOutputBadge)
registry.register("ProjectOutputDependency", ProjectOutputDependency)
registry.register("ProjectOutputAttachments", ProjectOutputAttachments)
registry.register("ProjectOutputForm", ProjectOutputForm)
data = gen_render_data()
rendered = render(data)
assert rendered == snapshot