mirror of
https://github.com/django-components/django-components.git
synced 2025-07-07 17:34:59 +00:00

* 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
6439 lines
196 KiB
Python
6439 lines
196 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 pathlib import Path
|
|
from types import MappingProxyType
|
|
from typing import (
|
|
Any,
|
|
Callable,
|
|
Dict,
|
|
Iterable,
|
|
List,
|
|
Literal,
|
|
NamedTuple,
|
|
Optional,
|
|
Tuple,
|
|
Type,
|
|
TypedDict,
|
|
TypeVar,
|
|
Union,
|
|
)
|
|
|
|
import django
|
|
from django import forms
|
|
from django.conf import settings
|
|
from django.http import HttpRequest
|
|
from django.middleware import csrf
|
|
from django.template import Context, Template
|
|
from django.template.defaultfilters import title
|
|
from django.utils.safestring import mark_safe
|
|
from django.utils.timezone import now
|
|
from django.contrib.humanize.templatetags.humanize import naturaltime
|
|
from django.template.defaulttags import register as default_library
|
|
|
|
from django_components import types, registry
|
|
|
|
# DO NOT REMOVE - See https://github.com/django-components/django-components/pull/999
|
|
# ----------- IMPORTS END ------------ #
|
|
|
|
# This variable is overridden by the benchmark runner
|
|
CONTEXT_MODE: Literal["django", "isolated"] = "isolated"
|
|
|
|
if not settings.configured:
|
|
settings.configure(
|
|
BASE_DIR=Path(__file__).resolve().parent,
|
|
INSTALLED_APPS=["django_components"],
|
|
TEMPLATES=[
|
|
{
|
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
|
"DIRS": [
|
|
"tests/templates/",
|
|
"tests/components/", # Required for template relative imports in tests
|
|
],
|
|
"OPTIONS": {
|
|
"builtins": [
|
|
"django_components.templatetags.component_tags",
|
|
]
|
|
},
|
|
}
|
|
],
|
|
COMPONENTS={
|
|
"autodiscover": False,
|
|
"context_behavior": CONTEXT_MODE,
|
|
},
|
|
MIDDLEWARE=[],
|
|
DATABASES={
|
|
"default": {
|
|
"ENGINE": "django.db.backends.sqlite3",
|
|
"NAME": ":memory:",
|
|
}
|
|
},
|
|
SECRET_KEY="secret",
|
|
ROOT_URLCONF="django_components.urls",
|
|
)
|
|
django.setup()
|
|
else:
|
|
settings.COMPONENTS["context_behavior"] = CONTEXT_MODE
|
|
|
|
#####################################
|
|
#
|
|
# IMPLEMENTATION START
|
|
#
|
|
#####################################
|
|
|
|
templates_cache: Dict[int, Template] = {}
|
|
|
|
def lazy_load_template(template: str) -> Template:
|
|
template_hash = hash(template)
|
|
if template_hash in templates_cache:
|
|
return templates_cache[template_hash]
|
|
else:
|
|
template_instance = Template(template)
|
|
templates_cache[template_hash] = template_instance
|
|
return template_instance
|
|
|
|
#####################################
|
|
# RENDER ENTRYPOINT
|
|
#####################################
|
|
|
|
def gen_render_data():
|
|
data = load_project_data_from_json(data_json)
|
|
|
|
# Generate Request and User
|
|
users = data.pop("users")
|
|
user = users[0]
|
|
|
|
bookmarks: List[ProjectBookmark] = [
|
|
{
|
|
"id": 82,
|
|
"project": data["project"],
|
|
"text": "Test bookmark",
|
|
"url": "http://localhost:8000/bookmarks/9/create",
|
|
"attachment": None,
|
|
}
|
|
]
|
|
|
|
request = HttpRequest()
|
|
request.user = user
|
|
request.method = "GET"
|
|
request.path = "/projects/1"
|
|
|
|
data["layout_data"] = ProjectLayoutData(
|
|
bookmarks=bookmarks,
|
|
project=data["project"],
|
|
active_projects=[data["project"]],
|
|
request=request,
|
|
)
|
|
|
|
return data
|
|
|
|
|
|
def render(data):
|
|
# Render
|
|
result = project_page(
|
|
Context(),
|
|
ProjectPageData(**data)
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
#####################################
|
|
# DATA
|
|
#####################################
|
|
|
|
data_json = """
|
|
{
|
|
"project": {
|
|
"pk": 1,
|
|
"fields": {
|
|
"name": "Project Name",
|
|
"organization": 1,
|
|
"status": "INPROGRESS",
|
|
"start_date": "2022-02-06",
|
|
"end_date": "2024-02-07"
|
|
}
|
|
},
|
|
"project_tags": [],
|
|
"phases": [
|
|
{
|
|
"pk": 8,
|
|
"fields": {
|
|
"project": 1,
|
|
"phase_template": 3
|
|
}
|
|
},
|
|
{
|
|
"pk": 7,
|
|
"fields": {
|
|
"project": 1,
|
|
"phase_template": 4
|
|
}
|
|
},
|
|
{
|
|
"pk": 6,
|
|
"fields": {
|
|
"project": 1,
|
|
"phase_template": 5
|
|
}
|
|
},
|
|
{
|
|
"pk": 5,
|
|
"fields": {
|
|
"project": 1,
|
|
"phase_template": 6
|
|
}
|
|
},
|
|
{
|
|
"pk": 4,
|
|
"fields": {
|
|
"project": 1,
|
|
"phase_template": 2
|
|
}
|
|
}
|
|
],
|
|
"notes_1": [
|
|
{
|
|
"pk": 1,
|
|
"fields": {
|
|
"created": "2025-02-07T08:59:58.689Z",
|
|
"modified": "2025-02-07T08:59:58.689Z",
|
|
"project": 1,
|
|
"text": "Test note 1"
|
|
}
|
|
},
|
|
{
|
|
"pk": 2,
|
|
"fields": {
|
|
"created": "2025-02-07T08:59:58.689Z",
|
|
"modified": "2025-02-07T08:59:58.689Z",
|
|
"project": 1,
|
|
"text": "Test note 2"
|
|
}
|
|
}
|
|
],
|
|
"comments_by_notes_1": {
|
|
"1": [
|
|
{
|
|
"pk": 3,
|
|
"fields": {
|
|
"parent": 1,
|
|
"notes": "Test note one two three",
|
|
"modified_by": 1
|
|
}
|
|
},
|
|
{
|
|
"pk": 4,
|
|
"fields": {
|
|
"parent": 1,
|
|
"notes": "Test note 2",
|
|
"modified_by": 1
|
|
}
|
|
}
|
|
]
|
|
},
|
|
"notes_2": [
|
|
{
|
|
"pk": 1,
|
|
"fields": {
|
|
"created": "2024-02-07T11:20:49.085Z",
|
|
"modified": "2024-02-07T11:20:55.003Z",
|
|
"project": 1,
|
|
"text": "Test note x"
|
|
}
|
|
}
|
|
],
|
|
"comments_by_notes_2": {
|
|
"1": [
|
|
{
|
|
"pk": 1,
|
|
"fields": {
|
|
"parent": 1,
|
|
"text": "Test note 6",
|
|
"modified_by": 1
|
|
}
|
|
},
|
|
{
|
|
"pk": 2,
|
|
"fields": {
|
|
"parent": 1,
|
|
"text": "Test note 5",
|
|
"modified_by": 1
|
|
}
|
|
},
|
|
{
|
|
"pk": 4,
|
|
"fields": {
|
|
"parent": 1,
|
|
"text": "Test note 4",
|
|
"modified_by": 1
|
|
}
|
|
},
|
|
{
|
|
"pk": 6,
|
|
"fields": {
|
|
"parent": 1,
|
|
"text": "Test note 3",
|
|
"modified_by": 1
|
|
}
|
|
}
|
|
]
|
|
},
|
|
"notes_3": [
|
|
{
|
|
"pk": 2,
|
|
"fields": {
|
|
"created": "2024-02-07T11:20:49.085Z",
|
|
"modified": "2024-02-07T11:20:55.003Z",
|
|
"project": 1,
|
|
"text": "Test note 2"
|
|
}
|
|
}
|
|
],
|
|
"comments_by_notes_3": {
|
|
"2": [
|
|
{
|
|
"pk": 1,
|
|
"fields": {
|
|
"parent": 2,
|
|
"text": "Test note 1",
|
|
"modified_by": 1
|
|
}
|
|
},
|
|
{
|
|
"pk": 3,
|
|
"fields": {
|
|
"parent": 2,
|
|
"text": "Test note 0",
|
|
"modified_by": 1
|
|
}
|
|
}
|
|
]
|
|
},
|
|
"roles_with_users": [
|
|
{
|
|
"pk": 6,
|
|
"fields": {
|
|
"user": 2,
|
|
"project": 1,
|
|
"name": "Assistant"
|
|
}
|
|
},
|
|
{
|
|
"pk": 7,
|
|
"fields": {
|
|
"user": 2,
|
|
"project": 1,
|
|
"name": "Owner"
|
|
}
|
|
}
|
|
],
|
|
"contacts": [],
|
|
"outputs": [
|
|
[
|
|
{
|
|
"pk": 14,
|
|
"fields": {
|
|
"name": "Lorem ipsum 16",
|
|
"description": "",
|
|
"completed": false,
|
|
"phase": 8,
|
|
"dependency": null
|
|
}
|
|
},
|
|
[],
|
|
[]
|
|
],
|
|
[
|
|
{
|
|
"pk": 15,
|
|
"fields": {
|
|
"name": "Lorem ipsum 15",
|
|
"description": "",
|
|
"completed": false,
|
|
"phase": 8,
|
|
"dependency": null
|
|
}
|
|
},
|
|
[],
|
|
[]
|
|
],
|
|
[
|
|
{
|
|
"pk": 16,
|
|
"fields": {
|
|
"name": "Lorem ipsum 14",
|
|
"description": "",
|
|
"completed": false,
|
|
"phase": 8,
|
|
"dependency": null
|
|
}
|
|
},
|
|
[],
|
|
[]
|
|
],
|
|
[
|
|
{
|
|
"pk": 17,
|
|
"fields": {
|
|
"name": "Lorem ipsum 13",
|
|
"description": "",
|
|
"completed": false,
|
|
"phase": 8,
|
|
"dependency": null
|
|
}
|
|
},
|
|
[],
|
|
[]
|
|
],
|
|
[
|
|
{
|
|
"pk": 18,
|
|
"fields": {
|
|
"name": "Lorem ipsum 12",
|
|
"description": "",
|
|
"completed": true,
|
|
"phase": 4,
|
|
"dependency": null
|
|
}
|
|
},
|
|
[
|
|
[
|
|
{
|
|
"pk": 19,
|
|
"fields": {
|
|
"text": "Test bookmark",
|
|
"url": "http://localhost:8000/create/bookmmarks/9/",
|
|
"created_by": 1,
|
|
"output": 18
|
|
}
|
|
},
|
|
[]
|
|
]
|
|
],
|
|
[]
|
|
],
|
|
[
|
|
{
|
|
"pk": 20,
|
|
"fields": {
|
|
"name": "Lorem ipsum 11",
|
|
"description": "",
|
|
"completed": false,
|
|
"phase": 7,
|
|
"dependency": 14
|
|
}
|
|
},
|
|
[],
|
|
[
|
|
[
|
|
{
|
|
"pk": 14,
|
|
"fields": {
|
|
"name": "Lorem ipsum 10",
|
|
"description": "",
|
|
"completed": false,
|
|
"phase": 8,
|
|
"dependency": null
|
|
}
|
|
},
|
|
[]
|
|
]
|
|
]
|
|
],
|
|
[
|
|
{
|
|
"pk": 21,
|
|
"fields": {
|
|
"name": "Lorem ipsum 9",
|
|
"description": "",
|
|
"completed": false,
|
|
"phase": 7,
|
|
"dependency": null
|
|
}
|
|
},
|
|
[],
|
|
[]
|
|
],
|
|
[
|
|
{
|
|
"pk": 22,
|
|
"fields": {
|
|
"name": "Lorem ipsum 8",
|
|
"description": "",
|
|
"completed": false,
|
|
"phase": 7,
|
|
"dependency": null
|
|
}
|
|
},
|
|
[],
|
|
[]
|
|
],
|
|
[
|
|
{
|
|
"pk": 23,
|
|
"fields": {
|
|
"name": "Lorem ipsum 7",
|
|
"description": "",
|
|
"completed": false,
|
|
"phase": 7,
|
|
"dependency": null
|
|
}
|
|
},
|
|
[],
|
|
[]
|
|
],
|
|
[
|
|
{
|
|
"pk": 24,
|
|
"fields": {
|
|
"name": "Lorem ipsum 6",
|
|
"description": "",
|
|
"completed": false,
|
|
"phase": 7,
|
|
"dependency": null
|
|
}
|
|
},
|
|
[],
|
|
[]
|
|
],
|
|
[
|
|
{
|
|
"pk": 25,
|
|
"fields": {
|
|
"name": "Lorem ipsum 5",
|
|
"description": "",
|
|
"completed": false,
|
|
"phase": 6,
|
|
"dependency": null
|
|
}
|
|
},
|
|
[],
|
|
[]
|
|
],
|
|
[
|
|
{
|
|
"pk": 26,
|
|
"fields": {
|
|
"name": "Lorem ipsum 4",
|
|
"description": "",
|
|
"completed": false,
|
|
"phase": 6,
|
|
"dependency": null
|
|
}
|
|
},
|
|
[],
|
|
[]
|
|
],
|
|
[
|
|
{
|
|
"pk": 27,
|
|
"fields": {
|
|
"name": "Lorem ipsum 3",
|
|
"description": "",
|
|
"completed": false,
|
|
"phase": 5,
|
|
"dependency": null
|
|
}
|
|
},
|
|
[],
|
|
[]
|
|
],
|
|
[
|
|
{
|
|
"pk": 28,
|
|
"fields": {
|
|
"name": "Lorem ipsum 2",
|
|
"description": "",
|
|
"completed": false,
|
|
"phase": 7,
|
|
"dependency": 14
|
|
}
|
|
},
|
|
[],
|
|
[
|
|
[
|
|
{
|
|
"pk": 14,
|
|
"fields": {
|
|
"name": "Lorem ipsum 1",
|
|
"description": "",
|
|
"completed": false,
|
|
"phase": 8,
|
|
"dependency": null
|
|
}
|
|
},
|
|
[]
|
|
]
|
|
]
|
|
]
|
|
],
|
|
"status_updates": [],
|
|
"user_is_project_member": true,
|
|
"user_is_project_owner": true,
|
|
"phase_titles": {
|
|
"PHASE_0": "Phase 0",
|
|
"PHASE_1": "Phase 1",
|
|
"PHASE_2": "Phase 2",
|
|
"PHASE_3": "Phase 3",
|
|
"PHASE_4": "Phase 4",
|
|
"PHASE_5": "Phase 5",
|
|
"LEGACY": "Legacy"
|
|
},
|
|
"users": [
|
|
{
|
|
"pk": 2,
|
|
"fields": {
|
|
"name": "UserName",
|
|
"is_staff": true
|
|
}
|
|
}
|
|
],
|
|
"organizations": [
|
|
{
|
|
"pk": 1,
|
|
"fields": {
|
|
"created": "2025-02-07T16:27:49.837Z",
|
|
"modified": "2025-02-07T16:27:49.837Z",
|
|
"name": "Org Name"
|
|
}
|
|
}
|
|
],
|
|
"phase_templates": [
|
|
{
|
|
"pk": 3,
|
|
"fields": {
|
|
"created": "2025-02-07T16:27:49.837Z",
|
|
"modified": "2025-02-07T16:27:49.837Z",
|
|
"name": "Phase 3",
|
|
"description": "## Phase 3",
|
|
"type": "PHASE_3"
|
|
}
|
|
},
|
|
{
|
|
"pk": 4,
|
|
"fields": {
|
|
"created": "2025-02-07T16:27:49.837Z",
|
|
"modified": "2025-02-07T16:27:49.837Z",
|
|
"name": "Phase 2",
|
|
"description": "## Phase 2",
|
|
"type": "PHASE_2"
|
|
}
|
|
},
|
|
{
|
|
"pk": 5,
|
|
"fields": {
|
|
"created": "2025-02-07T16:27:49.837Z",
|
|
"modified": "2025-02-07T16:27:49.837Z",
|
|
"name": "Phase 4",
|
|
"description": "## Phase 4",
|
|
"type": "PHASE_4"
|
|
}
|
|
},
|
|
{
|
|
"pk": 6,
|
|
"fields": {
|
|
"created": "2025-02-07T16:27:49.837Z",
|
|
"modified": "2025-02-07T16:27:49.837Z",
|
|
"name": "Phase 5",
|
|
"description": "## Phase 5",
|
|
"type": "PHASE_5"
|
|
}
|
|
},
|
|
{
|
|
"pk": 2,
|
|
"fields": {
|
|
"created": "2025-02-07T16:27:49.837Z",
|
|
"modified": "2025-02-07T16:27:49.837Z",
|
|
"name": "Phase 1",
|
|
"description": "## Phase 1",
|
|
"type": "PHASE_1"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
"""
|
|
|
|
#####################################
|
|
# DATA LOADER
|
|
#####################################
|
|
|
|
|
|
def load_project_data_from_json(contents: str) -> dict:
|
|
"""
|
|
Loads project data from JSON and resolves references between objects.
|
|
Returns the data with all resolvable references replaced with actual object references.
|
|
"""
|
|
data = json.loads(contents)
|
|
|
|
# First create lookup tables for objects that will be referenced
|
|
users_by_id = {user["pk"]: {"id": user["pk"], **user["fields"]} for user in data.get("users", [])}
|
|
|
|
def _get_user(user_id: int):
|
|
return users_by_id[user_id] if user_id in users_by_id else data.get("users", [])[0]
|
|
|
|
organizations_by_id = {org["pk"]: {"id": org["pk"], **org["fields"]} for org in data.get("organizations", [])}
|
|
|
|
phase_templates_by_id = {pt["pk"]: {"id": pt["pk"], **pt["fields"]} for pt in data.get("phase_templates", [])}
|
|
|
|
# 1. Resolve project's organization reference
|
|
project = {"id": data["project"]["pk"], **data["project"]["fields"]}
|
|
if "organization" in project:
|
|
org_id = project.pop("organization") # Remove the ID field
|
|
project["organization"] = organizations_by_id[org_id] # Add the reference
|
|
|
|
# 2. Project tags - no changes needed
|
|
project_tags = data["project_tags"]
|
|
|
|
# 3. Resolve phases' references
|
|
phases = []
|
|
phases_by_id = {} # We'll need this for resolving output references later
|
|
for phase_data in data["phases"]:
|
|
phase = {"id": phase_data["pk"], **phase_data["fields"]}
|
|
if "project" in phase:
|
|
phase["project"] = project
|
|
if "phase_template" in phase:
|
|
template_id = phase.pop("phase_template")
|
|
phase["phase_template"] = phase_templates_by_id[template_id]
|
|
phases.append(phase)
|
|
phases_by_id[phase["id"]] = phase
|
|
|
|
# 4. Resolve notes_1 references
|
|
notes_1 = []
|
|
notes_1_by_id = {} # We'll need this for resolving notes references
|
|
for note_data in data["notes_1"]:
|
|
note = {"id": note_data["pk"], **note_data["fields"]}
|
|
if "project" in note:
|
|
note["project"] = project
|
|
notes_1.append(note)
|
|
notes_1_by_id[note["id"]] = note
|
|
|
|
# 5. Resolve comments_by_notes_1 references
|
|
comments_by_notes_1 = {}
|
|
for note_id, comments_list in data["comments_by_notes_1"].items():
|
|
resolved_comments = []
|
|
for comment_data in comments_list:
|
|
comment = {"id": comment_data["pk"], **comment_data["fields"]}
|
|
if "modified_by" in comment:
|
|
comment["modified_by"] = _get_user(comment["modified_by"])
|
|
if "parent" in comment:
|
|
comment["parent"] = notes_1_by_id[comment["parent"]]
|
|
resolved_comments.append(comment)
|
|
comments_by_notes_1[note_id] = resolved_comments
|
|
|
|
# 6. Resolve notes_2' references
|
|
notes_2 = []
|
|
notes_2_by_id = {} # We'll need this for resolving notes references
|
|
for note_data in data["notes_2"]:
|
|
note = {"id": note_data["pk"], **note_data["fields"]}
|
|
if "project" in note:
|
|
note["project"] = project
|
|
notes_2.append(note)
|
|
notes_2_by_id[note["id"]] = note
|
|
|
|
# 7. Resolve comments_by_notes_2 references
|
|
comments_by_notes_2 = {}
|
|
for note_id, comments_list in data["comments_by_notes_2"].items():
|
|
resolved_comments = []
|
|
for comment_data in comments_list:
|
|
comment = {"id": comment_data["pk"], **comment_data["fields"]}
|
|
if "modified_by" in comment:
|
|
comment["modified_by"] = _get_user(comment["modified_by"])
|
|
if "parent" in comment:
|
|
comment["parent"] = notes_2_by_id[comment["parent"]]
|
|
resolved_comments.append(comment)
|
|
comments_by_notes_2[note_id] = resolved_comments
|
|
|
|
# 8. Resolve notes_3 references
|
|
notes_3 = []
|
|
notes_3_by_id = {} # We'll need this for resolving notes references
|
|
for note_data in data["notes_3"]:
|
|
note = {"id": note_data["pk"], **note_data["fields"]}
|
|
if "project" in note:
|
|
note["project"] = project
|
|
notes_3.append(note)
|
|
notes_3_by_id[note["id"]] = note
|
|
|
|
# 9. Resolve comments_by_notes_3 references
|
|
comments_by_notes_3 = {}
|
|
for note_id, comments_list in data["comments_by_notes_3"].items():
|
|
resolved_comments = []
|
|
for comment_data in comments_list:
|
|
comment = {"id": comment_data["pk"], **comment_data["fields"]}
|
|
if "modified_by" in comment:
|
|
comment["modified_by"] = _get_user(comment["modified_by"])
|
|
if "parent" in comment:
|
|
comment["parent"] = notes_3_by_id[comment["parent"]]
|
|
resolved_comments.append(comment)
|
|
comments_by_notes_3[note_id] = resolved_comments
|
|
|
|
# 10. Resolve roles_with_users references
|
|
roles = []
|
|
for role_data in data["roles_with_users"]:
|
|
role = {"id": role_data["pk"], **role_data["fields"]}
|
|
if "project" in role:
|
|
role["project"] = project
|
|
if "user" in role:
|
|
role["user"] = _get_user(role["user"])
|
|
roles.append(role)
|
|
|
|
# 11. Contacts - EMPTY, so no changes needed
|
|
contacts = data["contacts"]
|
|
|
|
# 12. Resolve outputs references
|
|
resolved_outputs = []
|
|
outputs_by_id = {} # For resolving dependencies
|
|
|
|
# First pass: Create all output objects and build lookup
|
|
for output_tuple in data["outputs"]:
|
|
output_data = output_tuple[0]
|
|
output = {"id": output_data["pk"], **output_data["fields"]}
|
|
if "phase" in output:
|
|
output["phase"] = phases_by_id[output["phase"]]
|
|
outputs_by_id[output["id"]] = output
|
|
|
|
# Second pass: Process each output with its attachments and dependencies
|
|
for output_tuple in data["outputs"]:
|
|
output_data, attachments_data, dependencies_data = output_tuple
|
|
output = outputs_by_id[output_data["pk"]]
|
|
|
|
# Process attachments
|
|
resolved_attachments = []
|
|
for attachment_tuple in attachments_data:
|
|
attachment_data = attachment_tuple[0]
|
|
attachment = {"id": attachment_data["pk"], **attachment_data["fields"]}
|
|
if "created_by" in attachment:
|
|
attachment["created_by"] = _get_user(attachment["created_by"])
|
|
if "output" in attachment:
|
|
attachment["output"] = outputs_by_id[attachment["output"]]
|
|
# Keep tags as is
|
|
resolved_attachments.append((attachment, attachment_tuple[1]))
|
|
|
|
# Process dependencies
|
|
resolved_dependencies = []
|
|
for dep_tuple in dependencies_data:
|
|
dep_data = dep_tuple[0]
|
|
dep_output = outputs_by_id[dep_data["pk"]]
|
|
# Keep the tuple structure but with resolved references
|
|
resolved_dependencies.append((dep_output, dep_tuple[1]))
|
|
|
|
resolved_outputs.append((output, resolved_attachments, resolved_dependencies))
|
|
|
|
return {
|
|
"project": project,
|
|
"project_tags": project_tags,
|
|
"phases": phases,
|
|
"notes_1": notes_1,
|
|
"comments_by_notes_1": comments_by_notes_1,
|
|
"notes_2": notes_2,
|
|
"comments_by_notes_2": comments_by_notes_2,
|
|
"notes_3": notes_3,
|
|
"comments_by_notes_3": comments_by_notes_3,
|
|
"roles_with_users": roles,
|
|
"contacts": contacts,
|
|
"outputs": resolved_outputs,
|
|
"status_updates": data["status_updates"],
|
|
"user_is_project_member": data["user_is_project_member"],
|
|
"user_is_project_owner": data["user_is_project_owner"],
|
|
"phase_titles": data["phase_titles"],
|
|
"users": data["users"],
|
|
}
|
|
|
|
|
|
#####################################
|
|
# TYPES
|
|
#####################################
|
|
|
|
|
|
class User(TypedDict):
|
|
id: int
|
|
name: str
|
|
|
|
|
|
class Organization(TypedDict):
|
|
id: int
|
|
name: str
|
|
|
|
|
|
class Project(TypedDict):
|
|
id: int
|
|
name: str
|
|
organization: Organization
|
|
status: str
|
|
start_date: date
|
|
end_date: date
|
|
|
|
|
|
class ProjectRole(TypedDict):
|
|
id: int
|
|
user: User
|
|
project: Project
|
|
name: str
|
|
|
|
|
|
class ProjectBookmark(TypedDict):
|
|
id: int
|
|
project: Project
|
|
text: str
|
|
url: str
|
|
attachment: Optional["ProjectOutputAttachment"]
|
|
|
|
|
|
class ProjectStatusUpdate(TypedDict):
|
|
id: int
|
|
project: Project
|
|
text: str
|
|
modified_by: User
|
|
modified: str
|
|
|
|
|
|
class ProjectContact(TypedDict):
|
|
id: int
|
|
project: Project
|
|
link_id: str
|
|
name: str
|
|
job: str
|
|
|
|
|
|
class PhaseTemplate(TypedDict):
|
|
id: int
|
|
name: str
|
|
description: str
|
|
type: str
|
|
|
|
|
|
class ProjectPhase(TypedDict):
|
|
id: int
|
|
project: Project
|
|
phase_template: PhaseTemplate
|
|
|
|
|
|
class ProjectOutput(TypedDict):
|
|
id: int
|
|
name: str
|
|
description: str
|
|
completed: bool
|
|
phase: ProjectPhase
|
|
dependency: Optional["ProjectOutput"]
|
|
|
|
|
|
class ProjectOutputAttachment(TypedDict):
|
|
id: int
|
|
text: str
|
|
url: str
|
|
created_by: User
|
|
output: ProjectOutput
|
|
|
|
|
|
class ProjectNote(TypedDict):
|
|
id: int
|
|
project: Project
|
|
text: str
|
|
created: str
|
|
|
|
|
|
class ProjectNoteComment(TypedDict):
|
|
id: int
|
|
parent: ProjectNote
|
|
text: str
|
|
modified_by: User
|
|
modified: str
|
|
|
|
|
|
#####################################
|
|
# CONSTANTS
|
|
#####################################
|
|
|
|
FORM_SHORT_TEXT_MAX_LEN = 255
|
|
|
|
|
|
# This allows us to compare Enum values against strings
|
|
class StrEnum(str, Enum):
|
|
pass
|
|
|
|
|
|
class TagResourceType(StrEnum):
|
|
PROJECT = "PROJECT"
|
|
PROJECT_BOOKMARK = "PROJECT_BOOKMARK"
|
|
PROJECT_OUTPUT = "PROJECT_OUTPUT"
|
|
PROJECT_OUTPUT_ATTACHMENT = "PROJECT_OUTPUT_ATTACHMENT"
|
|
PROJECT_TEMPLATE = "PROJECT_TEMPLATE"
|
|
|
|
|
|
class ProjectPhaseType(StrEnum):
|
|
PHASE_1 = "PHASE_1"
|
|
PHASE_2 = "PHASE_2"
|
|
PHASE_3 = "PHASE_3"
|
|
PHASE_4 = "PHASE_4"
|
|
PHASE_5 = "PHASE_5"
|
|
|
|
|
|
class TagTypeMeta(NamedTuple):
|
|
allowed_values: Tuple[str, ...]
|
|
|
|
|
|
# Additional metadata for Tags
|
|
#
|
|
# NOTE: We use MappingProxyType as an immutable dict.
|
|
# See https://stackoverflow.com/questions/2703599
|
|
TAG_TYPE_META = MappingProxyType(
|
|
{
|
|
TagResourceType.PROJECT: TagTypeMeta(
|
|
allowed_values=(
|
|
"Tag 1",
|
|
"Tag 2",
|
|
"Tag 3",
|
|
"Tag 4",
|
|
),
|
|
),
|
|
TagResourceType.PROJECT_BOOKMARK: TagTypeMeta(
|
|
allowed_values=(
|
|
"Tag 5",
|
|
"Tag 6",
|
|
"Tag 7",
|
|
"Tag 8",
|
|
),
|
|
),
|
|
TagResourceType.PROJECT_OUTPUT: TagTypeMeta(
|
|
allowed_values=tuple(),
|
|
),
|
|
TagResourceType.PROJECT_OUTPUT_ATTACHMENT: TagTypeMeta(
|
|
allowed_values=(
|
|
"Tag 9",
|
|
"Tag 10",
|
|
"Tag 11",
|
|
"Tag 12",
|
|
"Tag 13",
|
|
"Tag 14",
|
|
"Tag 15",
|
|
"Tag 16",
|
|
"Tag 17",
|
|
"Tag 18",
|
|
"Tag 19",
|
|
"Tag 20",
|
|
),
|
|
),
|
|
TagResourceType.PROJECT_TEMPLATE: TagTypeMeta(
|
|
allowed_values=("Tag 21",),
|
|
),
|
|
}
|
|
)
|
|
|
|
|
|
class ProjectOutputDef(NamedTuple):
|
|
title: str
|
|
description: Optional[str] = None
|
|
dependency: Optional[str] = None
|
|
|
|
|
|
class ProjectPhaseMeta(NamedTuple):
|
|
type: ProjectPhaseType
|
|
outputs: List[ProjectOutputDef]
|
|
|
|
|
|
# This constant decides in which order the project phases are shown,
|
|
# as well as what kind of name of description they have.
|
|
#
|
|
# NOTE: We use MappingProxyType as an immutable dict.
|
|
# See https://stackoverflow.com/questions/2703599
|
|
PROJECT_PHASES_META = MappingProxyType(
|
|
{
|
|
ProjectPhaseType.PHASE_1: ProjectPhaseMeta(
|
|
type=ProjectPhaseType.PHASE_1,
|
|
outputs=[
|
|
ProjectOutputDef(title="Lorem ipsum 0"),
|
|
],
|
|
),
|
|
ProjectPhaseType.PHASE_2: ProjectPhaseMeta(
|
|
type=ProjectPhaseType.PHASE_2,
|
|
outputs=[
|
|
ProjectOutputDef(title="Lorem ipsum 1"),
|
|
ProjectOutputDef(title="Lorem ipsum 2"),
|
|
ProjectOutputDef(title="Lorem ipsum 3"),
|
|
ProjectOutputDef(title="Lorem ipsum 4"),
|
|
],
|
|
),
|
|
ProjectPhaseType.PHASE_3: ProjectPhaseMeta(
|
|
type=ProjectPhaseType.PHASE_3,
|
|
outputs=[
|
|
ProjectOutputDef(
|
|
title="Lorem ipsum 6",
|
|
dependency="Lorem ipsum 1",
|
|
),
|
|
ProjectOutputDef(
|
|
title="Lorem ipsum 7",
|
|
dependency="Lorem ipsum 1",
|
|
),
|
|
ProjectOutputDef(title="Lorem ipsum 8"),
|
|
ProjectOutputDef(title="Lorem ipsum 9"),
|
|
ProjectOutputDef(title="Lorem ipsum 10"),
|
|
ProjectOutputDef(title="Lorem ipsum 11"),
|
|
],
|
|
),
|
|
ProjectPhaseType.PHASE_4: ProjectPhaseMeta(
|
|
type=ProjectPhaseType.PHASE_4,
|
|
outputs=[
|
|
ProjectOutputDef(title="Lorem ipsum 12"),
|
|
ProjectOutputDef(title="Lorem ipsum 13"),
|
|
],
|
|
),
|
|
ProjectPhaseType.PHASE_5: ProjectPhaseMeta(
|
|
type=ProjectPhaseType.PHASE_5,
|
|
outputs=[
|
|
ProjectOutputDef(title="Lorem ipsum 14"),
|
|
],
|
|
),
|
|
}
|
|
)
|
|
|
|
#####################################
|
|
# THEME
|
|
#####################################
|
|
|
|
ThemeColor = Literal["default", "error", "success", "alert", "info"]
|
|
ThemeVariant = Literal["primary", "secondary"]
|
|
|
|
VARIANTS = ["primary", "secondary"]
|
|
|
|
|
|
class ThemeStylingUnit(NamedTuple):
|
|
"""
|
|
Smallest unit of info, this class defines a specific styling of a specific
|
|
component in a specific state.
|
|
|
|
E.g. styling of a disabled "Error" button.
|
|
"""
|
|
|
|
color: str
|
|
"""CSS class(es) specifying color"""
|
|
css: str = ""
|
|
"""Other CSS classes not specific to color"""
|
|
|
|
|
|
class ThemeStylingVariant(NamedTuple):
|
|
"""
|
|
Collection of styling combinations that are meaningful as a group.
|
|
|
|
E.g. all "error" variants - primary, disabled, secondary, ...
|
|
"""
|
|
|
|
primary: ThemeStylingUnit
|
|
primary_disabled: ThemeStylingUnit
|
|
secondary: ThemeStylingUnit
|
|
secondary_disabled: ThemeStylingUnit
|
|
|
|
|
|
class Theme(NamedTuple):
|
|
"""Class for defining a styling and color theme for the app."""
|
|
|
|
default: ThemeStylingVariant
|
|
error: ThemeStylingVariant
|
|
alert: ThemeStylingVariant
|
|
success: ThemeStylingVariant
|
|
info: ThemeStylingVariant
|
|
|
|
sidebar: str
|
|
sidebar_link: str
|
|
background: str
|
|
tab_active: str
|
|
tab_text_active: str
|
|
tab_text_inactive: str
|
|
check_interactive: str
|
|
check_static: str
|
|
check_outline: str
|
|
|
|
|
|
_secondary_btn_styling = "ring-1 ring-inset"
|
|
|
|
theme = Theme(
|
|
default=ThemeStylingVariant(
|
|
primary=ThemeStylingUnit(
|
|
color="bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600 transition"
|
|
),
|
|
primary_disabled=ThemeStylingUnit(color="bg-blue-300 text-blue-50 focus-visible:outline-blue-600 transition"),
|
|
secondary=ThemeStylingUnit(
|
|
color="bg-white text-gray-800 ring-gray-300 hover:bg-gray-100 focus-visible:outline-gray-600 transition",
|
|
css=_secondary_btn_styling,
|
|
),
|
|
secondary_disabled=ThemeStylingUnit(
|
|
color="bg-white text-gray-300 ring-gray-300 focus-visible:outline-gray-600 transition",
|
|
css=_secondary_btn_styling,
|
|
),
|
|
),
|
|
error=ThemeStylingVariant(
|
|
primary=ThemeStylingUnit(color="bg-red-600 text-white hover:bg-red-500 focus-visible:outline-red-600"),
|
|
primary_disabled=ThemeStylingUnit(color="bg-red-300 text-white focus-visible:outline-red-600"),
|
|
secondary=ThemeStylingUnit(
|
|
color="bg-white text-red-600 ring-red-300 hover:bg-red-100 focus-visible:outline-red-600",
|
|
css=_secondary_btn_styling,
|
|
),
|
|
secondary_disabled=ThemeStylingUnit(
|
|
color="bg-white text-red-200 ring-red-100 focus-visible:outline-red-600",
|
|
css=_secondary_btn_styling,
|
|
),
|
|
),
|
|
alert=ThemeStylingVariant(
|
|
primary=ThemeStylingUnit(color="bg-amber-500 text-white hover:bg-amber-400 focus-visible:outline-amber-500"),
|
|
primary_disabled=ThemeStylingUnit(color="bg-amber-100 text-orange-300 focus-visible:outline-amber-500"),
|
|
secondary=ThemeStylingUnit(
|
|
color="bg-white text-amber-500 ring-amber-300 hover:bg-amber-100 focus-visible:outline-amber-500",
|
|
css=_secondary_btn_styling,
|
|
),
|
|
secondary_disabled=ThemeStylingUnit(
|
|
color="bg-white text-orange-200 ring-amber-100 focus-visible:outline-amber-500",
|
|
css=_secondary_btn_styling,
|
|
),
|
|
),
|
|
success=ThemeStylingVariant(
|
|
primary=ThemeStylingUnit(color="bg-green-600 text-white hover:bg-green-500 focus-visible:outline-green-600"),
|
|
primary_disabled=ThemeStylingUnit(color="bg-green-300 text-white focus-visible:outline-green-600"),
|
|
secondary=ThemeStylingUnit(
|
|
color="bg-white text-green-600 ring-green-300 hover:bg-green-100 focus-visible:outline-green-600",
|
|
css=_secondary_btn_styling,
|
|
),
|
|
secondary_disabled=ThemeStylingUnit(
|
|
color="bg-white text-green-200 ring-green-100 focus-visible:outline-green-600",
|
|
css=_secondary_btn_styling,
|
|
),
|
|
),
|
|
info=ThemeStylingVariant(
|
|
primary=ThemeStylingUnit(color="bg-sky-600 text-white hover:bg-sky-500 focus-visible:outline-sky-600"),
|
|
primary_disabled=ThemeStylingUnit(color="bg-sky-300 text-white focus-visible:outline-sky-600"),
|
|
secondary=ThemeStylingUnit(
|
|
color="bg-white text-sky-600 ring-sky-300 hover:bg-sky-100 focus-visible:outline-sky-600",
|
|
css=_secondary_btn_styling,
|
|
),
|
|
secondary_disabled=ThemeStylingUnit(
|
|
color="bg-white text-sky-200 ring-sky-100 focus-visible:outline-sky-600",
|
|
css=_secondary_btn_styling,
|
|
),
|
|
),
|
|
sidebar="bg-neutral-900 text-neutral-200",
|
|
sidebar_link="hover:bg-neutral-700 hover:text-white transition",
|
|
background="bg-neutral-200",
|
|
tab_active="border-blue-700",
|
|
tab_text_active="text-blue-700",
|
|
tab_text_inactive="text-gray-500 hover:text-blue-700",
|
|
check_interactive="bg-blue-600 group-hover:bg-blue-500 transition",
|
|
check_static="bg-blue-600",
|
|
check_outline="border-2 border-blue-600 bg-white",
|
|
)
|
|
|
|
|
|
def get_styling_css(
|
|
variant: Optional["ThemeVariant"] = None,
|
|
color: Optional["ThemeColor"] = None,
|
|
disabled: Optional[bool] = None,
|
|
):
|
|
"""
|
|
Dynamically access CSS styling classes for a specific variant and state.
|
|
|
|
E.g. following two calls get styling classes for:
|
|
1. Secondary error state
|
|
1. Secondary alert disabled state
|
|
2. Primary default disabled state
|
|
```py
|
|
get_styling_css('secondary', 'error')
|
|
get_styling_css('secondary', 'alert', disabled=True)
|
|
get_styling_css(disabled=True)
|
|
```
|
|
"""
|
|
variant = variant or "primary"
|
|
color = color or "default"
|
|
disabled = disabled if disabled is not None else False
|
|
|
|
color_variants: ThemeStylingVariant = getattr(theme, color)
|
|
|
|
if variant not in VARIANTS:
|
|
raise ValueError(f'Unknown theme variant "{variant}", must be one of {VARIANTS}')
|
|
|
|
variant_name = variant if not disabled else f"{variant}_disabled"
|
|
styling: ThemeStylingUnit = getattr(color_variants, variant_name)
|
|
|
|
css = f"{styling.color} {styling.css}".strip()
|
|
return css
|
|
|
|
|
|
#####################################
|
|
# HELPERS
|
|
#####################################
|
|
|
|
T = TypeVar("T")
|
|
U = TypeVar("U")
|
|
|
|
|
|
def format_timestamp(timestamp: datetime):
|
|
"""
|
|
If the timestamp is more than 7 days ago, format it as "Jan 1, 2025".
|
|
Otherwise, format it as a natural time string (e.g. "3 days ago").
|
|
"""
|
|
if now() - timestamp > timedelta(days=7):
|
|
return timestamp.strftime("%b %-d, %Y")
|
|
else:
|
|
return naturaltime(timestamp)
|
|
|
|
|
|
def group_by(
|
|
lst: Iterable[T],
|
|
keyfn: Callable[[T, int], Any],
|
|
mapper: Optional[Callable[[T, int], U]] = None,
|
|
):
|
|
"""
|
|
Given a list, generates a key for each item in the list using the `keyfn`.
|
|
|
|
Returns a dictionary of generated keys, where each value is a list of corresponding
|
|
items.
|
|
|
|
Similar to Lodash's `groupby`.
|
|
|
|
Optionally map the values in the lists with `mapper`.
|
|
"""
|
|
grouped: Dict[Any, List[Union[U, T]]] = {}
|
|
for index, item in enumerate(lst):
|
|
key = dynamic_apply(keyfn, item, index)
|
|
if key not in grouped:
|
|
grouped[key] = []
|
|
|
|
mapped_item = dynamic_apply(mapper, item, index) if mapper else item
|
|
grouped[key].append(mapped_item)
|
|
return grouped
|
|
|
|
|
|
def dynamic_apply(fn: Callable, *args):
|
|
"""
|
|
Given a function and positional arguments that should be applied to given function,
|
|
this helper will apply only as many arguments as the function defines, or only
|
|
as much as the number of arguments that we can apply.
|
|
"""
|
|
mapper_args_count = len(signature(fn).parameters)
|
|
num_args_to_apply = min(mapper_args_count, len(args))
|
|
first_n_args = args[:num_args_to_apply]
|
|
|
|
return fn(*first_n_args)
|
|
|
|
|
|
#####################################
|
|
# SHARED FORMS
|
|
#####################################
|
|
|
|
|
|
class ConditionalEditForm(forms.Form):
|
|
"""
|
|
Subclass of Django's Form that sets all fields as NON-editable based
|
|
on the `editable` field.
|
|
"""
|
|
|
|
editable: bool = True
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
|
|
if self.editable is not None and not self.editable:
|
|
self._disable_all_form_fields()
|
|
|
|
def _disable_all_form_fields(self):
|
|
fields: Dict[str, forms.Field] = self.fields # type: ignore[assignment]
|
|
for form_field in fields.values():
|
|
form_field.widget.attrs["readonly"] = True
|
|
|
|
|
|
#####################################
|
|
# TEMPLATE TAG FILTERS
|
|
#####################################
|
|
|
|
|
|
@default_library.filter("alpine")
|
|
def to_alpine_json(value: dict):
|
|
"""
|
|
Serialize Python object such that it can be passed to Alpine callbacks
|
|
in Django templates.
|
|
"""
|
|
# Avoid using double quotes since this value is passed to an HTML element
|
|
# attribute.
|
|
# NOTE: Maybe we could use HTML escaping to avoid the issue with double quotes?
|
|
data = json.dumps(value).replace('"', "'")
|
|
return data
|
|
|
|
|
|
@default_library.filter("json")
|
|
def to_json(value: dict):
|
|
"""Serialize Python object to JSON."""
|
|
data = json.dumps(value)
|
|
return data
|
|
|
|
|
|
@default_library.simple_tag
|
|
def define(val=None):
|
|
return val
|
|
|
|
|
|
@default_library.filter
|
|
def get_item(dictionary: dict, key: str):
|
|
return dictionary.get(key)
|
|
|
|
|
|
@default_library.filter("js")
|
|
def serialize_to_js(obj):
|
|
"""
|
|
Serialize a Python object to a JS-like expression.
|
|
Works recursively with nested dictionaries and lists.
|
|
|
|
So given a dict
|
|
`{"a": 123, "b": "console.log('abc')", "c": "'mystring'"}`
|
|
|
|
The filter exports:
|
|
`"{ a: 123, b: console.log('abc'), c: 'mystring' }"`
|
|
"""
|
|
if isinstance(obj, dict):
|
|
# If the object is a dictionary, iterate through key-value pairs
|
|
items = []
|
|
for key, value in obj.items():
|
|
serialized_value = serialize_to_js(value) # Recursively serialize the value
|
|
items.append(f"{key}: {serialized_value}")
|
|
return f"{{ {', '.join(items)} }}"
|
|
|
|
elif isinstance(obj, (list, tuple)):
|
|
# If the object is a list, recursively serialize each item
|
|
serialized_items = [serialize_to_js(item) for item in obj]
|
|
return f"[{', '.join(serialized_items)}]"
|
|
|
|
elif isinstance(obj, str):
|
|
return obj
|
|
|
|
else:
|
|
# For other types (int, float, etc.), just return the string representation
|
|
return str(obj)
|
|
|
|
|
|
#####################################
|
|
# BUTTON
|
|
#####################################
|
|
|
|
button_template_str: types.django_html = """
|
|
{# Based on buttons from https://tailwindui.com/components/application-ui/overlays/modals #}
|
|
|
|
{% if is_link %}
|
|
<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 }}
|
|
|
|
{% if is_link %}
|
|
</a>
|
|
{% else %}
|
|
</button>
|
|
{% endif %}
|
|
"""
|
|
|
|
|
|
class ButtonData(NamedTuple):
|
|
href: Optional[str] = None
|
|
link: Optional[bool] = None
|
|
disabled: Optional[bool] = False
|
|
variant: Union["ThemeVariant", Literal["plain"]] = "primary"
|
|
color: Union["ThemeColor", str] = "default"
|
|
type: Optional[str] = "button"
|
|
attrs: Optional[dict] = None
|
|
slot_content: Optional[str] = ""
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def button(context: Context, data: ButtonData):
|
|
common_css = (
|
|
"inline-flex w-full text-sm font-semibold"
|
|
" sm:mt-0 sm:w-auto focus-visible:outline-2 focus-visible:outline-offset-2"
|
|
)
|
|
if data.variant == "plain":
|
|
all_css_class = common_css
|
|
else:
|
|
button_classes = get_styling_css(data.variant, data.color, data.disabled) # type: ignore[arg-type]
|
|
all_css_class = f"{button_classes} {common_css} px-3 py-2 justify-center rounded-md shadow-sm"
|
|
|
|
is_link = not data.disabled and (data.href or data.link)
|
|
|
|
all_attrs = {**(data.attrs or {})}
|
|
if data.disabled:
|
|
all_attrs["aria-disabled"] = "true"
|
|
|
|
with context.push(
|
|
{
|
|
"href": data.href,
|
|
"disabled": data.disabled,
|
|
"type": data.type,
|
|
"btn_class": all_css_class,
|
|
"attrs": all_attrs,
|
|
"is_link": is_link,
|
|
"slot_content": data.slot_content,
|
|
}
|
|
):
|
|
return lazy_load_template(button_template_str).render(context)
|
|
|
|
|
|
#####################################
|
|
# MENU
|
|
#####################################
|
|
|
|
MaybeNestedList = List[Union[T, List[T]]]
|
|
MenuItemGroup = List["MenuItem"]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MenuItem:
|
|
"""
|
|
Single menu item used with the `menu` components.
|
|
|
|
Menu items can be divided by a horizontal line to indicate that the items
|
|
belong together. In code, we specify this by wrapping the item(s) as an array.
|
|
|
|
```py
|
|
menu_items = [
|
|
# Group 1
|
|
[
|
|
MenuItem(value="Edit", link="#"),
|
|
MenuItem(value="Duplicate"),
|
|
],
|
|
# Group 2
|
|
MenuItem(value="Add step before"),
|
|
MenuItem(value="Add step after"),
|
|
MenuItem(value="Add child step"),
|
|
# Group 3
|
|
[
|
|
MenuItem(value="Delete"),
|
|
],
|
|
]
|
|
```
|
|
"""
|
|
|
|
value: Any
|
|
"""Value of the menu item to render."""
|
|
|
|
link: Optional[str] = None
|
|
"""
|
|
If set, the menu item will be wrapped in an `<a>` tag pointing to this
|
|
link.
|
|
"""
|
|
|
|
item_attrs: Optional[dict] = None
|
|
"""HTML attributes specific to this menu item."""
|
|
|
|
|
|
menu_template_str: types.django_html = """
|
|
{# Based on https://tailwindui.com/components/application-ui/elements/dropdowns #}
|
|
|
|
{% comment %}
|
|
NOTE: {{ model }} is the Alpine variable used for opening/closing. The variable name
|
|
is set dynamically, hence we use Django's double curly braces to refer to it.
|
|
{% endcomment %}
|
|
<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 slot_activator %}
|
|
<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 }}
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% menu_list menu_list_data %}
|
|
</div>
|
|
"""
|
|
|
|
|
|
class MenuData(NamedTuple):
|
|
items: MaybeNestedList[Union[MenuItem, str]]
|
|
model: Optional[str] = None
|
|
# CSS and HTML attributes
|
|
attrs: Optional[dict] = None
|
|
activator_attrs: Optional[dict] = None
|
|
list_attrs: Optional[dict] = None
|
|
# UX
|
|
close_on_esc: Optional[bool] = True
|
|
close_on_click_outside: Optional[bool] = True
|
|
anchor: Optional[str] = None
|
|
anchor_dir: Optional[str] = "bottom"
|
|
# Slots
|
|
slot_activator: Optional[str] = None
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def menu(context: Context, data: MenuData):
|
|
is_model_overriden = bool(data.model)
|
|
model = data.model or "open"
|
|
|
|
all_list_attrs: dict = {}
|
|
if data.list_attrs:
|
|
all_list_attrs.update(data.list_attrs)
|
|
if data.anchor:
|
|
all_list_attrs[f"x-anchor.{data.anchor_dir}"] = data.anchor
|
|
all_list_attrs.update(
|
|
{
|
|
"x-show": model,
|
|
"x-cloak": "",
|
|
}
|
|
)
|
|
|
|
menu_list_data = MenuListData(
|
|
items=data.items,
|
|
attrs=all_list_attrs,
|
|
)
|
|
|
|
with context.push(
|
|
{
|
|
"model": model,
|
|
"is_model_overriden": is_model_overriden,
|
|
"close_on_click_outside": data.close_on_click_outside,
|
|
"close_on_esc": data.close_on_esc,
|
|
"activator_attrs": data.activator_attrs,
|
|
"attrs": data.attrs,
|
|
"menu_list_data": menu_list_data,
|
|
"slot_activator": data.slot_activator,
|
|
}
|
|
):
|
|
return lazy_load_template(menu_template_str).render(context)
|
|
|
|
|
|
#####################################
|
|
# MENU LIST
|
|
#####################################
|
|
|
|
|
|
def _normalize_item(item: Union[MenuItem, str]):
|
|
# Wrap plain value in MenuItem
|
|
if not isinstance(item, MenuItem):
|
|
return MenuItem(value=item)
|
|
return item
|
|
|
|
|
|
# Normalize a list of MenuItems such that they are all in groups. We achieve
|
|
# this by collecting consecutive ungrouped items into a single group.
|
|
def _normalize_items_to_groups(items: MaybeNestedList[Union[MenuItem, str]]):
|
|
def is_group(item):
|
|
return isinstance(item, Iterable) and not isinstance(item, str)
|
|
|
|
groups: List[List[Union[MenuItem, str]]] = []
|
|
|
|
curr_group: Optional[List[Union[MenuItem, str]]] = None
|
|
for index, item_or_grp in enumerate(items):
|
|
group: List[Union[MenuItem, str]] = []
|
|
if isinstance(item_or_grp, Iterable) and not isinstance(item_or_grp, str):
|
|
group = item_or_grp
|
|
else:
|
|
if curr_group is not None:
|
|
group = curr_group
|
|
else:
|
|
group = curr_group = []
|
|
group.append(item_or_grp)
|
|
|
|
is_not_last = index < len(items) - 1
|
|
if is_not_last and not is_group(items[index + 1]):
|
|
continue
|
|
groups.append(group)
|
|
curr_group = None
|
|
|
|
return groups
|
|
|
|
|
|
def prepare_menu_items(items: MaybeNestedList[Union[MenuItem, str]]):
|
|
groups = _normalize_items_to_groups(items)
|
|
normalized_groups: List[MenuItemGroup] = []
|
|
|
|
for group in groups:
|
|
norm_group = list(map(_normalize_item, group))
|
|
normalized_groups.append(norm_group)
|
|
|
|
return normalized_groups
|
|
|
|
|
|
menu_list_template_str: types.django_html = """
|
|
{# Based on https://tailwindui.com/components/application-ui/elements/dropdowns #}
|
|
<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
|
|
|
|
|
|
class MenuListData(NamedTuple):
|
|
items: MaybeNestedList[Union[MenuItem, str]]
|
|
attrs: Optional[dict] = None
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def menu_list(context: Context, data: MenuListData):
|
|
item_groups = prepare_menu_items(data.items)
|
|
|
|
with context.push(
|
|
{
|
|
"item_groups": item_groups,
|
|
"attrs": data.attrs,
|
|
}
|
|
):
|
|
return lazy_load_template(menu_list_template_str).render(context)
|
|
|
|
|
|
#####################################
|
|
# TABLE
|
|
#####################################
|
|
|
|
|
|
class TableHeader(NamedTuple):
|
|
"""Table header data structure used with the `table` components."""
|
|
|
|
name: str
|
|
"""Header name, as displayed to the users."""
|
|
|
|
key: str
|
|
"""Dictionary key on `TableRow.cols` that holds data for this header."""
|
|
|
|
hidden: Optional[bool] = None
|
|
"""
|
|
Whether to hide the header. The column will still be rendered,
|
|
only the column title will be hidden.
|
|
"""
|
|
|
|
cell_attrs: Optional[dict] = None
|
|
"""HTML attributes specific to this table header cell."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TableCell:
|
|
"""Single table cell (row + col) used with the `table` components."""
|
|
|
|
value: Any
|
|
"""Value of the cell to render."""
|
|
|
|
colspan: int = 1
|
|
"""
|
|
How many columns should this cell occupy.
|
|
|
|
See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/td#colspan
|
|
"""
|
|
|
|
link: Optional[str] = None
|
|
"""
|
|
If set, the cell value will be wrapped in an `<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
|
|
|
|
|
|
table_template_str: 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>
|
|
"""
|
|
|
|
|
|
class TableData(NamedTuple):
|
|
headers: List[TableHeader]
|
|
rows: List[TableRow]
|
|
attrs: Optional[dict] = None
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def table(context: Context, data: TableData):
|
|
rows_to_render = [tuple([row, prepare_row_headers(row, data.headers)]) for row in data.rows]
|
|
|
|
with context.push(
|
|
{
|
|
"headers": data.headers,
|
|
"rows_to_render": rows_to_render,
|
|
"NULL_CELL": NULL_CELL,
|
|
"attrs": data.attrs,
|
|
}
|
|
):
|
|
return lazy_load_template(table_template_str).render(context)
|
|
|
|
|
|
#####################################
|
|
# ICON
|
|
#####################################
|
|
|
|
icon_template_str: types.django_html = """
|
|
<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 %}
|
|
{% heroicon heroicon_data %}
|
|
{{ slot_content }}
|
|
|
|
{% if href %}
|
|
</a>
|
|
{% else %}
|
|
</span>
|
|
{% endif %}
|
|
</div>
|
|
"""
|
|
|
|
|
|
class IconData(NamedTuple):
|
|
name: str
|
|
variant: Optional[str] = None
|
|
size: Optional[int] = None
|
|
stroke_width: Optional[float] = None
|
|
viewbox: Optional[str] = None
|
|
svg_attrs: Optional[dict] = None
|
|
# Note: Unlike the underlying icon component, this component uses color CSS classes
|
|
color: Optional[str] = ""
|
|
icon_color: Optional[str] = ""
|
|
text_color: Optional[str] = ""
|
|
href: Optional[str] = None
|
|
text_attrs: Optional[dict] = None
|
|
link_attrs: Optional[dict] = None
|
|
attrs: Optional[dict] = None
|
|
# Slots
|
|
slot_content: Optional[str] = None
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def icon(context: Context, data: IconData):
|
|
# Allow to set icon and text independently, or both at same time via `color` prop
|
|
icon_color = data.color if not data.icon_color else data.icon_color
|
|
text_color = data.color if not data.text_color else data.text_color
|
|
|
|
svg_attrs = data.svg_attrs.copy() if data.svg_attrs else {}
|
|
if not svg_attrs.get("class"):
|
|
svg_attrs["class"] = ""
|
|
svg_attrs["class"] += f" {icon_color or ''} h-6 w-6 shrink-0"
|
|
|
|
heroicon_data = HeroIconData(
|
|
name=data.name,
|
|
variant=data.variant,
|
|
size=data.size,
|
|
viewbox=data.viewbox,
|
|
stroke_width=data.stroke_width,
|
|
attrs=svg_attrs,
|
|
)
|
|
|
|
with context.push(
|
|
{
|
|
"name": data.name,
|
|
"variant": data.variant,
|
|
"size": data.size,
|
|
"viewbox": data.viewbox,
|
|
"stroke_width": data.stroke_width,
|
|
"svg_attrs": svg_attrs,
|
|
"text_color": text_color,
|
|
"text_attrs": data.text_attrs,
|
|
"link_attrs": data.link_attrs,
|
|
"href": data.href,
|
|
"attrs": data.attrs,
|
|
"heroicon_data": heroicon_data,
|
|
"slot_content": data.slot_content,
|
|
}
|
|
):
|
|
return lazy_load_template(icon_template_str).render(context)
|
|
|
|
|
|
#####################################
|
|
# HEROICONS
|
|
#####################################
|
|
|
|
# Single hard-coded icon
|
|
ICONS = {
|
|
"outline": {
|
|
"academic-cap": [
|
|
{
|
|
"stroke-linecap": "round",
|
|
"stroke-linejoin": "round",
|
|
"d": "M4.26 10.147a60.438 60.438 0 0 0-.491 6.347A48.62 48.62 0 0 1 12 20.904a48.62 48.62 0 0 1 8.232-4.41 60.46 60.46 0 0 0-.491-6.347m-15.482 0a50.636 50.636 0 0 0-2.658-.813A59.906 59.906 0 0 1 12 3.493a59.903 59.903 0 0 1 10.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.717 50.717 0 0 1 12 13.489a50.702 50.702 0 0 1 7.74-3.342M6.75 15a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm0 0v-3.675A55.378 55.378 0 0 1 12 8.443m-7.007 11.55A5.981 5.981 0 0 0 6.75 15.75v-1.5", # noqa: E501
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
|
|
class ComponentDefaultsMeta(type):
|
|
def __new__(mcs, name: str, bases: Tuple, namespace: Dict) -> Type:
|
|
# Apply dataclass decorator to the class
|
|
return dataclass(super().__new__(mcs, name, bases, namespace))
|
|
|
|
|
|
class ComponentDefaults(metaclass=ComponentDefaultsMeta):
|
|
def __post_init__(self) -> None:
|
|
fields = self.__class__.__dataclass_fields__ # type: ignore[attr-defined]
|
|
for field_name, dataclass_field in fields.items():
|
|
if dataclass_field.default is not MISSING:
|
|
if getattr(self, field_name) is None:
|
|
setattr(self, field_name, dataclass_field.default)
|
|
|
|
|
|
class IconDefaults(ComponentDefaults):
|
|
name: str
|
|
variant: str = "outline"
|
|
size: int = 24
|
|
color: str = "currentColor"
|
|
stroke_width: float = 1.5
|
|
viewbox: str = "0 0 24 24"
|
|
attrs: Optional[Dict] = None
|
|
|
|
|
|
heroicon_template_str: types.django_html = """
|
|
{% load component_tags %}
|
|
<svg {% html_attrs attrs default_attrs %}>
|
|
{% for path_attrs in icon_paths %}
|
|
<path {% html_attrs path_attrs %} />
|
|
{% endfor %}
|
|
</svg>
|
|
"""
|
|
|
|
|
|
class HeroIconData(NamedTuple):
|
|
name: str
|
|
variant: Optional[str] = None
|
|
size: Optional[int] = None
|
|
color: Optional[str] = None
|
|
stroke_width: Optional[float] = None
|
|
viewbox: Optional[str] = None
|
|
attrs: Optional[Dict] = None
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def heroicon(context: Context, data: HeroIconData):
|
|
kwargs = IconDefaults(**data._asdict())
|
|
|
|
if kwargs.variant not in ["outline", "solid"]:
|
|
raise ValueError(f"Invalid variant: {kwargs.variant}. Must be either 'outline' or 'solid'")
|
|
|
|
# variant_icons = ICONS[kwargs.variant]
|
|
variant_icons = ICONS["outline"]
|
|
icon_name = "academic-cap"
|
|
|
|
if icon_name not in variant_icons:
|
|
# Give users a helpful message by fuzzy-search the closest key
|
|
msg = ""
|
|
icon_names = list(variant_icons.keys())
|
|
if icon_names:
|
|
fuzzy_matches = difflib.get_close_matches(icon_name, icon_names, n=3, cutoff=0.7)
|
|
if fuzzy_matches:
|
|
suggestions = ", ".join([f"'{match}'" for match in fuzzy_matches])
|
|
msg += f". Did you mean any of {suggestions}?"
|
|
|
|
raise ValueError(f"Invalid icon name: {icon_name}{msg}")
|
|
|
|
icon_paths = variant_icons[icon_name]
|
|
|
|
# These are set as "default" attributes, so users can override them
|
|
# by passing them in the `attrs` argument.
|
|
default_attrs: Dict[str, Any] = {
|
|
"viewBox": kwargs.viewbox,
|
|
"style": f"width: {kwargs.size}px; height: {kwargs.size}px",
|
|
"aria-hidden": "true",
|
|
}
|
|
|
|
# The SVG applies the color differently in "outline" and "solid" versions
|
|
if kwargs.variant == "outline":
|
|
default_attrs["fill"] = "none"
|
|
default_attrs["stroke"] = kwargs.color
|
|
default_attrs["stroke-width"] = kwargs.stroke_width
|
|
else:
|
|
default_attrs["fill"] = kwargs.color
|
|
default_attrs["stroke"] = "none"
|
|
|
|
with context.push(
|
|
{
|
|
"icon_paths": icon_paths,
|
|
"default_attrs": default_attrs,
|
|
"attrs": kwargs.attrs,
|
|
}
|
|
):
|
|
return lazy_load_template(heroicon_template_str).render(context)
|
|
|
|
|
|
#####################################
|
|
# EXPANSION PANEL
|
|
#####################################
|
|
|
|
expansion_panel_template_str: types.django_html = """
|
|
<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" %}
|
|
{% icon expand_icon_data %}
|
|
{% endif %}
|
|
|
|
{{ slot_header }}
|
|
|
|
{% if icon_position == "right" %}
|
|
{% icon expand_icon_data %}
|
|
{% endif %}
|
|
</div>
|
|
<div x-show="isOpen" {% html_attrs content_attrs %}>
|
|
{{ slot_content }}
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
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;
|
|
},
|
|
}));
|
|
});
|
|
</script>
|
|
"""
|
|
|
|
|
|
class ExpansionPanelData(NamedTuple):
|
|
open: Optional[bool] = False
|
|
panel_id: Optional[str] = None
|
|
attrs: Optional[dict] = None
|
|
header_attrs: Optional[dict] = None
|
|
content_attrs: Optional[dict] = None
|
|
icon_position: Literal["left", "right"] = "left"
|
|
slot_header: Optional[str] = None
|
|
slot_content: Optional[str] = None
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def expansion_panel(context: Context, data: ExpansionPanelData):
|
|
init_data = {"open": data.open}
|
|
|
|
expand_icon_data = IconData(
|
|
name="chevron-down",
|
|
variant="outline",
|
|
attrs={
|
|
"style": "width: fit-content;",
|
|
"class": "{ 'rotate-180': isOpen }",
|
|
},
|
|
)
|
|
|
|
with context.push(
|
|
{
|
|
"attrs": data.attrs,
|
|
"header_attrs": data.header_attrs,
|
|
"content_attrs": data.content_attrs,
|
|
"icon_position": data.icon_position,
|
|
"init_data": init_data,
|
|
"panel_id": data.panel_id if data.panel_id else False,
|
|
"expand_icon_data": expand_icon_data,
|
|
"slot_header": data.slot_header,
|
|
"slot_content": data.slot_content,
|
|
}
|
|
):
|
|
return lazy_load_template(expansion_panel_template_str).render(context)
|
|
|
|
#####################################
|
|
# PROJECT_PAGE
|
|
#####################################
|
|
|
|
|
|
# Tabs on this page and the query params to open specific tabs on page load.
|
|
class ProjectPageTabsToQueryParams(Enum):
|
|
PROJECT_INFO = {"tabs-proj-right": "1"}
|
|
OUTPUTS = {"tabs-proj-right": "5"}
|
|
|
|
|
|
project_page_header_template_str: types.django_html = """
|
|
<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>
|
|
"""
|
|
|
|
project_page_tabs_template_str: types.django_html = """
|
|
{% for tab in project_page_tabs %}
|
|
{% tab_item tab %}
|
|
{% endfor %}
|
|
"""
|
|
|
|
|
|
class ProjectPageData(NamedTuple):
|
|
phases: List[ProjectPhase]
|
|
project_tags: List[str]
|
|
notes_1: List[ProjectNote]
|
|
comments_by_notes_1: Dict[str, List[ProjectNoteComment]]
|
|
notes_2: List[ProjectNote]
|
|
comments_by_notes_2: Dict[str, List[ProjectNoteComment]]
|
|
notes_3: List[ProjectNote]
|
|
comments_by_notes_3: Dict[str, List[ProjectNoteComment]]
|
|
status_updates: List[ProjectStatusUpdate]
|
|
roles_with_users: List[ProjectRole]
|
|
contacts: List[ProjectContact]
|
|
outputs: List["OutputWithAttachmentsAndDeps"]
|
|
user_is_project_member: bool
|
|
user_is_project_owner: bool
|
|
phase_titles: Dict[ProjectPhaseType, str]
|
|
# Used by project layout
|
|
layout_data: "ProjectLayoutData"
|
|
project: Project
|
|
breadcrumbs: Optional[List["Breadcrumb"]] = None
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def project_page(context: Context, data: ProjectPageData):
|
|
rendered_phases: List[ListItem] = []
|
|
phases_by_type = {p["phase_template"]["type"]: p for p in data.phases}
|
|
for phase_meta in PROJECT_PHASES_META.values():
|
|
phase = phases_by_type[phase_meta.type]
|
|
title = data.phase_titles[phase_meta.type]
|
|
rendered_phases.append(
|
|
ListItem(
|
|
value=title,
|
|
link=f"/projects/{data.project['id']}/phases/{phase['phase_template']['type']}",
|
|
)
|
|
)
|
|
|
|
project_page_tabs = [
|
|
TabItemData(
|
|
header="Project Info",
|
|
slot_content=project_info(
|
|
context,
|
|
ProjectInfoData(
|
|
project=data.project,
|
|
project_tags=data.project_tags,
|
|
roles_with_users=data.roles_with_users,
|
|
contacts=data.contacts,
|
|
status_updates=data.status_updates,
|
|
editable=data.user_is_project_owner,
|
|
)
|
|
),
|
|
),
|
|
TabItemData(
|
|
header="Notes 1",
|
|
slot_content=project_notes(
|
|
context,
|
|
ProjectNotesData(
|
|
project_id=data.project["id"],
|
|
notes=data.notes_1,
|
|
comments_by_notes=data.comments_by_notes_1, # type: ignore[arg-type]
|
|
editable=data.user_is_project_member,
|
|
)
|
|
),
|
|
),
|
|
TabItemData(
|
|
header="Notes 2",
|
|
slot_content=project_notes(
|
|
context,
|
|
ProjectNotesData(
|
|
project_id=data.project["id"],
|
|
notes=data.notes_2,
|
|
comments_by_notes=data.comments_by_notes_2, # type: ignore[arg-type]
|
|
editable=data.user_is_project_member,
|
|
)
|
|
),
|
|
),
|
|
TabItemData(
|
|
header="Notes 3",
|
|
slot_content=project_notes(
|
|
context,
|
|
ProjectNotesData(
|
|
project_id=data.project["id"],
|
|
notes=data.notes_3,
|
|
comments_by_notes=data.comments_by_notes_3, # type: ignore[arg-type]
|
|
editable=data.user_is_project_member,
|
|
)
|
|
),
|
|
),
|
|
TabItemData(
|
|
header="Outputs",
|
|
slot_content=project_outputs_summary(
|
|
context,
|
|
ProjectOutputsSummaryData(
|
|
project_id=data.project["id"],
|
|
outputs=data.outputs,
|
|
editable=data.user_is_project_member,
|
|
phase_titles=data.phase_titles,
|
|
)
|
|
),
|
|
),
|
|
]
|
|
|
|
def render_tabs(tabs_context: Context):
|
|
with tabs_context.push(
|
|
{
|
|
"project_page_tabs": project_page_tabs,
|
|
}
|
|
):
|
|
return lazy_load_template(project_page_tabs_template_str).render(tabs_context)
|
|
|
|
phases_content = list_tag(
|
|
context,
|
|
ListData(
|
|
items=rendered_phases,
|
|
item_attrs={"class": "py-5"},
|
|
)
|
|
)
|
|
with context.push(
|
|
{
|
|
"project": data.project,
|
|
}
|
|
):
|
|
header_content = lazy_load_template(project_page_header_template_str).render(context)
|
|
|
|
layout_tabbed_data = ProjectLayoutTabbedData(
|
|
layout_data=data.layout_data,
|
|
breadcrumbs=data.breadcrumbs,
|
|
top_level_tab_index=1,
|
|
slot_left_panel=phases_content,
|
|
slot_header=header_content,
|
|
slot_tabs=CallableSlot(render_tabs),
|
|
)
|
|
|
|
return project_layout_tabbed(context, layout_tabbed_data)
|
|
|
|
#####################################
|
|
# PROJECT_LAYOUT_TABBED
|
|
#####################################
|
|
|
|
|
|
class ProjectLayoutData(NamedTuple):
|
|
request: HttpRequest
|
|
active_projects: List[Project]
|
|
project: Project
|
|
bookmarks: List[ProjectBookmark]
|
|
|
|
|
|
def gen_tabs(project_id: int):
|
|
return [
|
|
TabStaticEntry(
|
|
header="Tab 2",
|
|
href=f"/projects/{project_id}/tab-2",
|
|
content=None,
|
|
),
|
|
TabStaticEntry(
|
|
header="Tab 1",
|
|
href=f"/projects/{project_id}/tab-1",
|
|
content=None,
|
|
),
|
|
]
|
|
|
|
|
|
project_layout_tabbed_content_template_str: types.django_html = """
|
|
{{ slot_header }}
|
|
|
|
{% if top_level_tab_index is not None %}
|
|
{% tabs_static content_tabs_static_data %}
|
|
{% endif %}
|
|
|
|
<div class="flex flex-auto gap-6">
|
|
|
|
{# Split the content to 2 columns, based on whether `left_panel` slot is filled #}
|
|
{% if slot_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 %}
|
|
|
|
<div class="h-full divide-y divide-gray-200 bg-white shadow overflow-y-hidden">
|
|
{% tabs content_tabs_data %}
|
|
</div>
|
|
|
|
{% if slot_left_panel %}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
"""
|
|
|
|
|
|
class ProjectLayoutTabbedData(NamedTuple):
|
|
layout_data: ProjectLayoutData
|
|
breadcrumbs: Optional[List["Breadcrumb"]] = None
|
|
top_level_tab_index: Optional[int] = None
|
|
variant: Literal["thirds", "halves"] = "thirds"
|
|
# Slots
|
|
slot_left_panel: Optional[str] = None
|
|
slot_js: Optional[str] = None
|
|
slot_css: Optional[str] = None
|
|
slot_header: Optional[str] = None
|
|
slot_tabs: Optional["CallableSlot"] = None
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def project_layout_tabbed(context: Context, data: ProjectLayoutTabbedData):
|
|
projects_url = "/projects"
|
|
curr_project_url = f"/projects/{data.layout_data.project['id']}"
|
|
|
|
prefixed_breadcrumbs = [
|
|
Breadcrumb(
|
|
link=projects_url,
|
|
value=icon(
|
|
context,
|
|
IconData(
|
|
name="home",
|
|
variant="outline",
|
|
size=20,
|
|
stroke_width=2,
|
|
color="text-gray-400 hover:text-gray-500",
|
|
),
|
|
),
|
|
),
|
|
Breadcrumb(value=data.layout_data.project["name"], link=curr_project_url),
|
|
*(data.breadcrumbs or []),
|
|
]
|
|
|
|
top_level_tabs = gen_tabs(data.layout_data.project["id"])
|
|
|
|
left_pannel_attrs = {
|
|
"class": "w-1/3" if data.variant == "thirds" else "w-1/2",
|
|
}
|
|
right_pannel_attrs = {
|
|
"class": "w-2/3" if data.variant == "thirds" else "w-1/2",
|
|
}
|
|
|
|
breadcrumbs_content = breadcrumbs_tag(context, BreadcrumbsData(items=prefixed_breadcrumbs))
|
|
bookmarks_content = bookmarks_tag(
|
|
context,
|
|
BookmarksData(bookmarks=data.layout_data.bookmarks, project_id=data.layout_data.project["id"])
|
|
)
|
|
|
|
content_tabs_static_data = TabsStaticData(
|
|
tabs=top_level_tabs,
|
|
tab_index=data.top_level_tab_index or 0,
|
|
)
|
|
|
|
content_tabs_data = TabsData(
|
|
name="proj-right",
|
|
attrs={"class": "p-6 h-full"},
|
|
content_attrs={"class": "flex flex-col"},
|
|
slot_content=data.slot_tabs,
|
|
)
|
|
|
|
with context.push(
|
|
{
|
|
"layout_data": data.layout_data,
|
|
"breadcrumbs": prefixed_breadcrumbs,
|
|
"bookmarks": data.layout_data.bookmarks,
|
|
"project": data.layout_data.project,
|
|
"top_level_tabs": top_level_tabs,
|
|
"top_level_tab_index": data.top_level_tab_index,
|
|
"theme": theme,
|
|
"left_pannel_attrs": left_pannel_attrs,
|
|
"right_pannel_attrs": right_pannel_attrs,
|
|
"content_tabs_static_data": content_tabs_static_data,
|
|
"content_tabs_data": content_tabs_data,
|
|
"slot_left_panel": data.slot_left_panel,
|
|
"slot_header": data.slot_header,
|
|
"slot_tabs": data.slot_tabs,
|
|
}
|
|
):
|
|
layout_content = lazy_load_template(project_layout_tabbed_content_template_str).render(context)
|
|
|
|
layout_data = LayoutData(
|
|
data=data.layout_data,
|
|
attrs=None,
|
|
slot_js=data.slot_js,
|
|
slot_css=data.slot_css,
|
|
slot_header=breadcrumbs_content,
|
|
slot_sidebar=bookmarks_content,
|
|
slot_content=layout_content,
|
|
)
|
|
|
|
return layout(context, layout_data)
|
|
|
|
|
|
#####################################
|
|
# LAYOUT
|
|
#####################################
|
|
|
|
# inputs:
|
|
# - attrs
|
|
# - sidebar_data
|
|
# - navbar_data
|
|
# - slot_header
|
|
# - slot_content
|
|
layout_base_content_template_str: types.django_html = """
|
|
<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,
|
|
}"
|
|
>
|
|
{% sidebar sidebar_data %}
|
|
</div>
|
|
|
|
<div :class="{ 'pl-72': sidebarOpen }" class="flex flex-col" style="height: 100vh;">
|
|
{% navbar navbar_data %}
|
|
|
|
<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 }}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
"""
|
|
|
|
|
|
layout_template_str: types.django_html = """
|
|
{% render_context_provider render_context_provider_data %}
|
|
"""
|
|
|
|
|
|
class LayoutData(NamedTuple):
|
|
data: ProjectLayoutData
|
|
attrs: Optional[dict] = None
|
|
# Slots
|
|
slot_js: Optional[str] = None
|
|
slot_css: Optional[str] = None
|
|
slot_header: Optional[str] = None
|
|
slot_content: Optional[str] = None
|
|
slot_sidebar: Optional[str] = None
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def layout(context: Context, data: LayoutData):
|
|
def render_context_provider_slot(provided_context: Context):
|
|
navbar_data = NavbarData(
|
|
attrs={
|
|
"@sidebar_toggle": "toggleSidebar",
|
|
},
|
|
)
|
|
|
|
sidebar_data = SidebarData(
|
|
active_projects=data.data.active_projects,
|
|
slot_content=data.slot_sidebar,
|
|
)
|
|
|
|
with provided_context.push(
|
|
{
|
|
"attrs": data.attrs,
|
|
"navbar_data": navbar_data,
|
|
"sidebar_data": sidebar_data,
|
|
"slot_header": data.slot_header,
|
|
"slot_content": data.slot_content,
|
|
}
|
|
):
|
|
layout_base_content = lazy_load_template(layout_base_content_template_str).render(provided_context)
|
|
|
|
base_data = BaseData(
|
|
slot_content=layout_base_content,
|
|
slot_css=data.slot_css,
|
|
slot_js=(data.slot_js or "")
|
|
+ """
|
|
<script>
|
|
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);
|
|
},
|
|
}));
|
|
});
|
|
</script>
|
|
""",
|
|
)
|
|
|
|
with provided_context.push(
|
|
{
|
|
"base_data": base_data,
|
|
}
|
|
):
|
|
return lazy_load_template("{% base base_data %}").render(provided_context)
|
|
|
|
render_context_provider_data = RenderContextProviderData(
|
|
request=data.data.request,
|
|
slot_content=CallableSlot(render=render_context_provider_slot),
|
|
)
|
|
|
|
with context.push(
|
|
{
|
|
"render_context_provider_data": render_context_provider_data,
|
|
}
|
|
):
|
|
return lazy_load_template(layout_template_str).render(context)
|
|
|
|
|
|
#####################################
|
|
# RENDER_CONTEXT_PROVIDER
|
|
#####################################
|
|
|
|
|
|
class RenderContext(NamedTuple):
|
|
"""
|
|
Data that's commonly available in all template rendering.
|
|
|
|
In templates, we can assume that the data defined here is ALWAYS defined.
|
|
"""
|
|
|
|
request: HttpRequest
|
|
user: User
|
|
csrf_token: str
|
|
|
|
|
|
class CallableSlot(NamedTuple):
|
|
render: Callable[[Context], str]
|
|
|
|
|
|
class RenderContextProviderData(NamedTuple):
|
|
request: HttpRequest
|
|
slot_content: Optional[CallableSlot] = None
|
|
|
|
|
|
# This component "provides" data. This is similar to ContextProviders
|
|
# in React, or the "provide" part of Vue's provide/inject feature.
|
|
#
|
|
# Components nested inside `RenderContextProvider` can access the
|
|
# data with `self.inject("render_context")`.
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def render_context_provider(context: Context, data: RenderContextProviderData) -> str:
|
|
if not data.slot_content:
|
|
return ""
|
|
|
|
csrf_token = csrf.get_token(data.request)
|
|
render_context = RenderContext(
|
|
request=data.request,
|
|
user=data.request.user,
|
|
csrf_token=csrf_token,
|
|
)
|
|
|
|
with context.push({"render_context": render_context}):
|
|
return data.slot_content.render(context)
|
|
|
|
|
|
#####################################
|
|
# BASE
|
|
#####################################
|
|
|
|
base_template_str: types.django_html = """
|
|
{% load static %}
|
|
|
|
<!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>
|
|
|
|
{{ slot_css }}
|
|
</head>
|
|
<body class="{{ theme.background }} h-full">
|
|
{{ slot_content }}
|
|
|
|
{# 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>
|
|
|
|
{# 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>
|
|
<script>
|
|
////////////////////////////////////////////////////////////////
|
|
// 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);
|
|
});
|
|
};
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
class BaseData(NamedTuple):
|
|
slot_css: Optional[str] = None
|
|
slot_js: Optional[str] = None
|
|
slot_content: Optional[str] = None
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def base(context: Context, data: BaseData) -> str:
|
|
render_context: RenderContext = context["render_context"]
|
|
|
|
with context.push(
|
|
{
|
|
"csrf_token": render_context.csrf_token,
|
|
"theme": theme,
|
|
# Slots
|
|
"slot_css": data.slot_css,
|
|
"slot_js": data.slot_js,
|
|
"slot_content": data.slot_content,
|
|
}
|
|
):
|
|
return lazy_load_template(base_template_str).render(context)
|
|
|
|
|
|
#####################################
|
|
# SIDEBAR
|
|
#####################################
|
|
|
|
|
|
class SidebarItem(NamedTuple):
|
|
name: str
|
|
icon: Optional[str] = None
|
|
icon_variant: Optional[str] = None
|
|
href: Optional[str] = None
|
|
children: Optional[List["SidebarItem"]] = None
|
|
|
|
|
|
# Links in the sidebar.
|
|
def gen_sidebar_menu_items(active_projects: List[Project]) -> List[Tuple[IconData, List[ButtonData]]]:
|
|
items: List[Tuple[IconData, List[ButtonData]]] = [
|
|
(
|
|
IconData(
|
|
name="home",
|
|
variant="outline",
|
|
href="/",
|
|
color=theme.sidebar_link,
|
|
text_attrs={
|
|
"class": "p-2",
|
|
},
|
|
slot_content="Homepage",
|
|
),
|
|
[],
|
|
),
|
|
(
|
|
IconData(
|
|
name="folder",
|
|
variant="outline",
|
|
href="/projects",
|
|
color=theme.sidebar_link,
|
|
text_attrs={
|
|
"class": "p-2",
|
|
},
|
|
slot_content="Projects",
|
|
),
|
|
[
|
|
ButtonData(
|
|
variant="plain",
|
|
href=f"/projects/{project['id']}",
|
|
slot_content=project["name"],
|
|
attrs={
|
|
"class": "p-2 !w-full",
|
|
},
|
|
)
|
|
for project in active_projects
|
|
],
|
|
),
|
|
(
|
|
IconData(
|
|
name="folder",
|
|
variant="outline",
|
|
href="/page-3",
|
|
color=theme.sidebar_link,
|
|
text_attrs={
|
|
"class": "p-2",
|
|
},
|
|
slot_content="Page 3",
|
|
),
|
|
[],
|
|
),
|
|
(
|
|
IconData(
|
|
name="bars-arrow-down",
|
|
variant="outline",
|
|
href="/page-4",
|
|
color=theme.sidebar_link,
|
|
text_attrs={
|
|
"class": "p-2",
|
|
},
|
|
slot_content="Page 4",
|
|
),
|
|
[],
|
|
),
|
|
(
|
|
IconData(
|
|
name="forward",
|
|
variant="outline",
|
|
href="/page-5",
|
|
color=theme.sidebar_link,
|
|
text_attrs={
|
|
"class": "p-2",
|
|
},
|
|
slot_content="Page 5",
|
|
),
|
|
[],
|
|
),
|
|
(
|
|
IconData(
|
|
name="archive-box",
|
|
variant="outline",
|
|
href="/faq",
|
|
color=theme.sidebar_link,
|
|
text_attrs={
|
|
"class": "p-2",
|
|
},
|
|
slot_content="FAQ",
|
|
),
|
|
[],
|
|
),
|
|
]
|
|
|
|
return items
|
|
|
|
|
|
sidebar_template_str: types.django_html = """
|
|
<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, children in items %}
|
|
<li>
|
|
{% icon sidebar_item %}
|
|
</li>
|
|
|
|
{% for child_item in children %}
|
|
<li class="ml-8 rounded-md {{ theme.sidebar_link }}">
|
|
{% button child_item %}
|
|
</li>
|
|
{% endfor %}
|
|
{% endfor %}
|
|
</ul>
|
|
|
|
<li class="mt-auto">
|
|
{% icon faq_icon_data %}
|
|
|
|
{% icon feedback_icon_data %}
|
|
</li>
|
|
|
|
{% if user.is_staff %}
|
|
<li>
|
|
{% icon download_icon_data %}
|
|
</li>
|
|
{% endif %}
|
|
</li>
|
|
</ul>
|
|
</nav>
|
|
</div>
|
|
"""
|
|
|
|
|
|
class SidebarData(NamedTuple):
|
|
active_projects: List[Project]
|
|
attrs: Optional[dict] = None
|
|
slot_content: Optional[str] = None
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def sidebar(context: Context, data: SidebarData):
|
|
render_context: RenderContext = context["render_context"]
|
|
user = render_context.user
|
|
items = gen_sidebar_menu_items(data.active_projects)
|
|
|
|
faq_url = "/faq"
|
|
|
|
download_icon_data = IconData(
|
|
name="document-arrow-down",
|
|
variant="outline",
|
|
color=theme.sidebar_link,
|
|
text_attrs={
|
|
"class": "p-2",
|
|
},
|
|
slot_content="Download",
|
|
)
|
|
|
|
feedback_icon_data = IconData(
|
|
name="megaphone",
|
|
variant="outline",
|
|
color=theme.sidebar_link,
|
|
text_attrs={
|
|
"class": "p-2",
|
|
},
|
|
link_attrs={
|
|
"target": "_blank",
|
|
},
|
|
slot_content="Feedback",
|
|
)
|
|
|
|
faq_icon_data = IconData(
|
|
name="user-group",
|
|
variant="outline",
|
|
href=faq_url,
|
|
color=theme.sidebar_link,
|
|
text_attrs={
|
|
"class": "p-2",
|
|
},
|
|
slot_content="FAQ",
|
|
)
|
|
|
|
with context.push(
|
|
{
|
|
"items": items,
|
|
"attrs": data.attrs,
|
|
"user": user,
|
|
"theme": theme,
|
|
"faq_url": faq_url,
|
|
"download_icon_data": download_icon_data,
|
|
"feedback_icon_data": feedback_icon_data,
|
|
"faq_icon_data": faq_icon_data,
|
|
# Slots
|
|
"slot_content": data.slot_content,
|
|
}
|
|
):
|
|
return lazy_load_template(sidebar_template_str).render(context)
|
|
|
|
|
|
#####################################
|
|
# NAVBAR
|
|
#####################################
|
|
|
|
navbar_template_str: types.django_html = """
|
|
<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>
|
|
{% icon sidebar_toggle_icon_data %}
|
|
</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
|
|
|
|
|
|
class NavbarData(NamedTuple):
|
|
attrs: Optional[dict] = None
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def navbar(context: Context, data: NavbarData):
|
|
sidebar_toggle_icon_data = IconData(
|
|
name="bars-3",
|
|
variant="outline",
|
|
)
|
|
|
|
with context.push(
|
|
{
|
|
"sidebar_toggle_icon_data": sidebar_toggle_icon_data,
|
|
"attrs": data.attrs,
|
|
}
|
|
):
|
|
return lazy_load_template(navbar_template_str).render(context)
|
|
|
|
|
|
#####################################
|
|
# DIALOG
|
|
#####################################
|
|
|
|
|
|
def construct_btn_onclick(model: str, btn_on_click: Optional[str]):
|
|
"""
|
|
We want to allow the component users to define Alpine.js `@click` actions.
|
|
However, we also need to use `@click` to close the dialog after clicking
|
|
one of the buttons.
|
|
|
|
Hence, this function constructs the '@click' attribute, such that we can do both.
|
|
|
|
NOTE: `model` is the name of the Alpine variable used by the dialog.
|
|
"""
|
|
on_click_cb = f"{model} = false;"
|
|
if btn_on_click:
|
|
on_click_cb = f"{btn_on_click}; {on_click_cb}"
|
|
return mark_safe(on_click_cb)
|
|
|
|
|
|
dialog_template_str: types.django_html = """
|
|
{# Based on https://tailwindui.com/components/application-ui/overlays/modals #}
|
|
|
|
{% comment %}
|
|
NOTE: {{ model }} is the Alpine variable used for opening/closing. The variable name
|
|
is set dynamically, hence we use Django's double curly braces to refer to it.
|
|
{% endcomment %}
|
|
<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 slot_activator %}
|
|
{# This is what opens the modal #}
|
|
<div
|
|
@click="{{ model }} = true"
|
|
{% html_attrs activator_attrs %}
|
|
>
|
|
{{ slot_activator }}
|
|
</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 slot_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 %}
|
|
{% button confirm_button_data %}
|
|
{% endif %}
|
|
|
|
{% if not cancel_hide %}
|
|
{% button cancel_button_data %}
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
""" # noqa: E501
|
|
|
|
|
|
class DialogData(NamedTuple):
|
|
model: Optional[str] = None
|
|
# Classes and HTML attributes
|
|
attrs: Optional[dict] = None
|
|
activator_attrs: Optional[dict] = None
|
|
title_attrs: Optional[dict] = None
|
|
content_attrs: Optional[dict] = None
|
|
# Confirm button
|
|
confirm_hide: Optional[bool] = None
|
|
confirm_text: Optional[str] = "Confirm"
|
|
confirm_href: Optional[str] = None
|
|
confirm_disabled: Optional[bool] = None
|
|
confirm_variant: Optional["ThemeVariant"] = "primary"
|
|
confirm_color: Optional["ThemeColor"] = None
|
|
confirm_type: Optional[str] = None
|
|
confirm_on_click: Optional[str] = ""
|
|
confirm_attrs: Optional[dict] = None
|
|
# Cancel button
|
|
cancel_hide: Optional[bool] = None
|
|
cancel_text: Optional[str] = "Cancel"
|
|
cancel_href: Optional[str] = None
|
|
cancel_disabled: Optional[bool] = None
|
|
cancel_variant: Optional["ThemeVariant"] = "secondary"
|
|
cancel_color: Optional["ThemeColor"] = None
|
|
cancel_type: Optional[str] = None
|
|
cancel_on_click: Optional[str] = ""
|
|
cancel_attrs: Optional[dict] = None
|
|
# UX
|
|
close_on_esc: Optional[bool] = True
|
|
close_on_click_outside: Optional[bool] = True
|
|
# Slots
|
|
slot_activator: Optional[str] = None
|
|
slot_prepend: Optional[str] = None
|
|
slot_title: Optional[str] = None
|
|
slot_content: Optional[str] = None
|
|
slot_append: Optional[str] = None
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def dialog(context: Context, data: DialogData):
|
|
is_model_overriden = bool(data.model)
|
|
model = data.model or "open"
|
|
|
|
# Modify "attrs" passed to buttons, so we close the dialog when clicking the buttons
|
|
cancel_attrs = {
|
|
**(data.cancel_attrs or {}),
|
|
"@click": construct_btn_onclick(model, data.cancel_on_click),
|
|
}
|
|
confirm_attrs = {
|
|
**(data.confirm_attrs or {}),
|
|
"@click": construct_btn_onclick(model, data.confirm_on_click),
|
|
}
|
|
|
|
confirm_button_data = ButtonData(
|
|
variant=data.confirm_variant, # type: ignore[arg-type]
|
|
color=data.confirm_color, # type: ignore[arg-type]
|
|
disabled=data.confirm_disabled,
|
|
href=data.confirm_href,
|
|
type=data.confirm_type,
|
|
attrs=confirm_attrs,
|
|
slot_content=data.confirm_text,
|
|
)
|
|
|
|
cancel_button_data = ButtonData(
|
|
variant=data.cancel_variant, # type: ignore[arg-type]
|
|
color=data.cancel_color, # type: ignore[arg-type]
|
|
disabled=data.cancel_disabled,
|
|
href=data.cancel_href,
|
|
type=data.cancel_type,
|
|
attrs=cancel_attrs,
|
|
slot_content=data.cancel_text,
|
|
)
|
|
|
|
with context.push(
|
|
{
|
|
"model": model,
|
|
"is_model_overriden": is_model_overriden,
|
|
# Classes and HTML attributes
|
|
"attrs": data.attrs,
|
|
"activator_attrs": data.activator_attrs,
|
|
"content_attrs": data.content_attrs,
|
|
"title_attrs": data.title_attrs,
|
|
# UX
|
|
"close_on_esc": data.close_on_esc,
|
|
"close_on_click_outside": data.close_on_click_outside,
|
|
# Confirm button
|
|
"confirm_hide": data.confirm_hide,
|
|
"confirm_text": data.confirm_text,
|
|
"confirm_href": data.confirm_href,
|
|
"confirm_disabled": data.confirm_disabled,
|
|
"confirm_variant": data.confirm_variant,
|
|
"confirm_color": data.confirm_color,
|
|
"confirm_type": data.confirm_type,
|
|
"confirm_attrs": confirm_attrs,
|
|
"confirm_button_data": confirm_button_data,
|
|
# Cancel button
|
|
"cancel_hide": data.cancel_hide,
|
|
"cancel_text": data.cancel_text,
|
|
"cancel_href": data.cancel_href,
|
|
"cancel_disabled": data.cancel_disabled,
|
|
"cancel_variant": data.cancel_variant,
|
|
"cancel_color": data.cancel_color,
|
|
"cancel_type": data.cancel_type,
|
|
"cancel_attrs": cancel_attrs,
|
|
"cancel_button_data": cancel_button_data,
|
|
# Slots
|
|
"slot_activator": data.slot_activator,
|
|
"slot_prepend": data.slot_prepend,
|
|
"slot_title": data.slot_title,
|
|
"slot_content": data.slot_content,
|
|
"slot_append": data.slot_append,
|
|
}
|
|
):
|
|
return lazy_load_template(dialog_template_str).render(context)
|
|
|
|
|
|
#####################################
|
|
# TAGS
|
|
#####################################
|
|
|
|
|
|
class TagEntry(NamedTuple):
|
|
tag: str
|
|
selected: bool = False
|
|
|
|
|
|
# JS props that can be passed to the Alpine component via python
|
|
class TagsJsProps(TypedDict):
|
|
initTags: str
|
|
|
|
|
|
tags_template_str: types.django_html = """
|
|
<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 }}
|
|
|
|
<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>
|
|
{% button remove_button_data %}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
{% if editable %}
|
|
<div x-show="tags.value.length < allTags.value.length">
|
|
{% button add_tag_button_data %}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
<script>
|
|
// 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);
|
|
});
|
|
</script>
|
|
"""
|
|
|
|
|
|
class TagsData(NamedTuple):
|
|
tag_type: str
|
|
js_props: dict
|
|
editable: bool = True
|
|
max_width: Union[int, str] = "300px"
|
|
attrs: Optional[dict] = None
|
|
slot_title: Optional[str] = None
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def tags(context: Context, data: TagsData):
|
|
all_tags = TAG_TYPE_META[data.tag_type.upper()].allowed_values # type: ignore[index]
|
|
|
|
remove_button_data = ButtonData(
|
|
color="error",
|
|
attrs={
|
|
"class": "!py-1",
|
|
"@click": "removeTag(index)",
|
|
},
|
|
slot_content="Remove",
|
|
)
|
|
|
|
add_tag_button_data = ButtonData(
|
|
attrs={
|
|
"class": "!py-1",
|
|
"@click": "addTag",
|
|
},
|
|
slot_content="Add tag",
|
|
)
|
|
|
|
slot_title = (
|
|
data.slot_title
|
|
or """
|
|
<p class="text-sm">
|
|
Tags:
|
|
</p>
|
|
"""
|
|
)
|
|
|
|
with context.push(
|
|
{
|
|
"editable": data.editable,
|
|
"all_tags": all_tags,
|
|
"max_width": data.max_width,
|
|
"attrs": data.attrs,
|
|
"js_props": data.js_props,
|
|
"remove_button_data": remove_button_data,
|
|
"add_tag_button_data": add_tag_button_data,
|
|
"slot_title": slot_title,
|
|
}
|
|
):
|
|
return lazy_load_template(tags_template_str).render(context)
|
|
|
|
|
|
#####################################
|
|
# FORM
|
|
#####################################
|
|
|
|
form_template_str: types.django_html = """
|
|
<form
|
|
{% 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 }}
|
|
</{{ 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 %}
|
|
{% button submit_button_data %}
|
|
{% endif %}
|
|
|
|
{% if not cancel_hide %}
|
|
{% button cancel_button_data %}
|
|
{% endif %}
|
|
|
|
{{ slot_actions_append }}
|
|
</div>
|
|
{% endif %}
|
|
</form>
|
|
<script>
|
|
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;
|
|
});
|
|
});
|
|
</script>
|
|
"""
|
|
|
|
|
|
class FormData(NamedTuple):
|
|
type: Literal["table", "paragraph", "ul", None] = None
|
|
editable: bool = True
|
|
method: str = "post"
|
|
# Submit btn
|
|
submit_hide: Optional[bool] = None
|
|
submit_text: Optional[str] = "Submit"
|
|
submit_href: Optional[str] = None
|
|
submit_disabled: Optional[bool] = None
|
|
submit_variant: Optional["ThemeVariant"] = "primary"
|
|
submit_color: Optional["ThemeColor"] = None
|
|
submit_type: Optional[str] = "submit"
|
|
submit_attrs: Optional[dict] = None
|
|
# Cancel btn
|
|
cancel_hide: Optional[bool] = None
|
|
cancel_text: Optional[str] = "Cancel"
|
|
cancel_href: Optional[str] = None
|
|
cancel_disabled: Optional[bool] = None
|
|
cancel_variant: Optional["ThemeVariant"] = "secondary"
|
|
cancel_color: Optional["ThemeColor"] = None
|
|
cancel_type: Optional[str] = "button"
|
|
cancel_attrs: Optional[dict] = None
|
|
# Actions
|
|
actions_hide: Optional[bool] = None
|
|
actions_attrs: Optional[dict] = None
|
|
# Other
|
|
form_content_attrs: Optional[dict] = None
|
|
attrs: Optional[dict] = None
|
|
# Slots
|
|
slot_actions_prepend: Optional[str] = None
|
|
slot_actions_append: Optional[str] = None
|
|
slot_form: Optional[str] = None
|
|
slot_below_form: Optional[str] = None
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def form(context: Context, data: FormData):
|
|
if data.type == "table":
|
|
form_content_tag = "table"
|
|
elif data.type == "paragraph":
|
|
form_content_tag = "div"
|
|
elif data.type == "ul":
|
|
form_content_tag = "ul"
|
|
else:
|
|
form_content_tag = "div"
|
|
|
|
# Add AlpineJS bindings to submit button
|
|
submit_attrs = {
|
|
**(data.submit_attrs or {}),
|
|
":disabled": "isSubmitting",
|
|
}
|
|
|
|
submit_button_data = ButtonData(
|
|
variant=data.submit_variant, # type: ignore[arg-type]
|
|
color=data.submit_color, # type: ignore[arg-type]
|
|
disabled=data.submit_disabled,
|
|
type=data.submit_type,
|
|
attrs=submit_attrs,
|
|
slot_content=data.submit_text,
|
|
)
|
|
|
|
cancel_button_data = ButtonData(
|
|
variant=data.cancel_variant, # type: ignore[arg-type]
|
|
color=data.cancel_color, # type: ignore[arg-type]
|
|
disabled=data.cancel_disabled,
|
|
href=data.cancel_href,
|
|
type=data.cancel_type,
|
|
attrs=data.cancel_attrs,
|
|
slot_content=data.cancel_text,
|
|
)
|
|
|
|
with context.push(
|
|
{
|
|
"form_content_tag": form_content_tag,
|
|
"form_content_attrs": data.form_content_attrs,
|
|
"method": data.method,
|
|
"editable": data.editable,
|
|
"submit_hide": data.submit_hide,
|
|
"submit_text": data.submit_text,
|
|
"submit_href": data.submit_href,
|
|
"submit_disabled": data.submit_disabled or not data.editable,
|
|
"submit_variant": data.submit_variant,
|
|
"submit_color": data.submit_color,
|
|
"submit_type": data.submit_type,
|
|
"submit_attrs": submit_attrs,
|
|
"cancel_hide": data.cancel_hide,
|
|
"cancel_text": data.cancel_text,
|
|
"cancel_href": data.cancel_href,
|
|
"cancel_disabled": data.cancel_disabled,
|
|
"cancel_variant": data.cancel_variant,
|
|
"cancel_color": data.cancel_color,
|
|
"cancel_type": data.cancel_type,
|
|
"cancel_attrs": data.cancel_attrs,
|
|
"actions_hide": data.actions_hide,
|
|
"actions_attrs": data.actions_attrs,
|
|
"attrs": data.attrs,
|
|
"cancel_button_data": cancel_button_data,
|
|
"submit_button_data": submit_button_data,
|
|
"slot_actions_prepend": data.slot_actions_prepend,
|
|
"slot_actions_append": data.slot_actions_append,
|
|
"slot_form": data.slot_form,
|
|
"slot_below_form": data.slot_below_form,
|
|
}
|
|
):
|
|
return lazy_load_template(form_template_str).render(context)
|
|
|
|
|
|
#####################################
|
|
# BREADCRUMBS
|
|
#####################################
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Breadcrumb:
|
|
"""
|
|
Single breadcrumb item used with the `breadcrumb` components.
|
|
"""
|
|
|
|
value: Any
|
|
"""Value of the menu item to render."""
|
|
|
|
link: Optional[str] = None
|
|
"""
|
|
If set, the item will be wrapped in an `<a>` tag pointing to this
|
|
link.
|
|
"""
|
|
|
|
item_attrs: Optional[dict] = None
|
|
"""HTML attributes specific to this item."""
|
|
|
|
|
|
breadcrumbs_template_str: 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>
|
|
"""
|
|
|
|
|
|
class BreadcrumbsData(NamedTuple):
|
|
items: List[Breadcrumb]
|
|
attrs: Optional[dict] = None
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def breadcrumbs(context: Context, data: BreadcrumbsData):
|
|
with context.push(
|
|
{
|
|
"items": data.items,
|
|
"attrs": data.attrs,
|
|
}
|
|
):
|
|
return lazy_load_template(breadcrumbs_template_str).render(context)
|
|
|
|
|
|
breadcrumbs_tag = breadcrumbs
|
|
|
|
#####################################
|
|
# BOOKMARKS
|
|
#####################################
|
|
|
|
item_class = "px-4 py-1 text-sm text-gray-900 hover:bg-gray-100 cursor-pointer"
|
|
menu_items = [
|
|
[
|
|
MenuItem(
|
|
value="Edit",
|
|
link="#",
|
|
item_attrs={
|
|
"class": item_class,
|
|
":href": "contextMenuItem.value.edit_url",
|
|
},
|
|
),
|
|
],
|
|
]
|
|
|
|
bookmarks_template_str: types.django_html = """
|
|
<li x-data="bookmarks" {% html_attrs attrs class="pt-4" %}>
|
|
{% icon bookmarks_icon_data %}
|
|
<ul class="mx-4">
|
|
{% for bookmark in bookmark_entries %}
|
|
{% bookmark bookmark %}
|
|
{% endfor %}
|
|
|
|
<li>
|
|
{% icon add_new_bookmark_icon_data %}
|
|
</li>
|
|
|
|
<div class="border-b border-gray-200 my-2 pt-2 text-sm font-bold">
|
|
Attachments:
|
|
</div>
|
|
|
|
{% for bookmark in attachment_entries %}
|
|
{% bookmark bookmark %}
|
|
{% endfor %}
|
|
</ul>
|
|
|
|
<template x-if="contextMenuItem.value">
|
|
<div class="self-center">
|
|
{% menu context_menu_data %}
|
|
</div>
|
|
</template>
|
|
<script>
|
|
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);
|
|
});
|
|
</script>
|
|
</li>
|
|
"""
|
|
|
|
|
|
class BookmarksData(NamedTuple):
|
|
project_id: int
|
|
bookmarks: List[ProjectBookmark]
|
|
attrs: Optional[dict] = None
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def bookmarks(context: Context, data: BookmarksData):
|
|
bookmark_entries: List[BookmarkData] = []
|
|
attachment_entries: List[BookmarkData] = []
|
|
|
|
for bookmark in data.bookmarks:
|
|
is_attachment = bookmark["attachment"] is not None
|
|
|
|
if is_attachment:
|
|
# Send user to the Output tab in Project page and open and scroll
|
|
# to the relevent output that has the correct attachment.
|
|
edit_url = (
|
|
f"/edit/{data.project_id}/bookmark/{bookmark['id']}"
|
|
f"?{ProjectPageTabsToQueryParams.OUTPUTS.value}"
|
|
f"&panel={bookmark['attachment']['output']['id']}" # type: ignore[index]
|
|
)
|
|
else:
|
|
edit_url = f"/edit/{data.project_id}/bookmark/{bookmark['id']}"
|
|
|
|
entry = BookmarkData(
|
|
bookmark=BookmarkItem(
|
|
text=bookmark["text"],
|
|
url=bookmark["url"],
|
|
id=bookmark["id"],
|
|
edit_url=edit_url,
|
|
),
|
|
js={
|
|
"onMenuToggle": "onContextMenuToggle",
|
|
},
|
|
)
|
|
|
|
if is_attachment:
|
|
attachment_entries.append(entry)
|
|
else:
|
|
bookmark_entries.append(entry)
|
|
|
|
create_bookmark_url = f"/create/{data.project_id}/bookmark"
|
|
|
|
bookmarks_icon_data = IconData(
|
|
name="bookmark",
|
|
variant="outline",
|
|
text_attrs={
|
|
"class": "py-2 text-sm",
|
|
},
|
|
slot_content="Project Bookmarks",
|
|
)
|
|
|
|
add_new_bookmark_icon_data = IconData(
|
|
name="plus",
|
|
variant="outline",
|
|
size=18,
|
|
href=create_bookmark_url,
|
|
color=theme.sidebar_link,
|
|
text_attrs={
|
|
"class": "px-2 py-1 text-xs",
|
|
},
|
|
svg_attrs={
|
|
"class": "mt-0.5 ml-1",
|
|
},
|
|
slot_content="Add New Bookmark",
|
|
)
|
|
|
|
context_menu_data = MenuData(
|
|
items=menu_items, # type: ignore[arg-type]
|
|
model="contextMenuItem.value",
|
|
anchor="contextMenuRef.value",
|
|
anchor_dir="bottom",
|
|
list_attrs={
|
|
"class": "w-24 ml-8 z-40",
|
|
},
|
|
attrs={
|
|
"@click_outside": "onContextMenuClickOutside",
|
|
},
|
|
)
|
|
|
|
with context.push(
|
|
{
|
|
"bookmark_entries": bookmark_entries,
|
|
"attachment_entries": attachment_entries,
|
|
"create_bookmark_url": create_bookmark_url,
|
|
"menu_items": menu_items,
|
|
"attrs": data.attrs,
|
|
"theme": theme,
|
|
"bookmarks_icon_data": bookmarks_icon_data,
|
|
"add_new_bookmark_icon_data": add_new_bookmark_icon_data,
|
|
"context_menu_data": context_menu_data,
|
|
}
|
|
):
|
|
return lazy_load_template(bookmarks_template_str).render(context)
|
|
|
|
|
|
bookmarks_tag = bookmarks
|
|
|
|
#####################################
|
|
# BOOKMARK
|
|
#####################################
|
|
|
|
|
|
class BookmarkItem(NamedTuple):
|
|
id: int
|
|
text: str
|
|
url: str
|
|
edit_url: str
|
|
|
|
|
|
bookmark_template_str: types.django_html = """
|
|
<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>
|
|
|
|
{% icon bookmark_icon_data %}
|
|
</div>
|
|
<script>
|
|
// 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);
|
|
});
|
|
</script>
|
|
</li>
|
|
"""
|
|
|
|
|
|
class BookmarkData(NamedTuple):
|
|
bookmark: BookmarkItem
|
|
js: Optional[dict] = None
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def bookmark(context: Context, data: BookmarkData):
|
|
bookmark_icon_data = IconData(
|
|
name="ellipsis-vertical",
|
|
variant="outline",
|
|
color=theme.sidebar_link,
|
|
svg_attrs={
|
|
"class": "inline",
|
|
},
|
|
text_attrs={
|
|
"class": "p-0",
|
|
},
|
|
attrs={
|
|
"class": "self-center cursor-pointer",
|
|
"x-ref": "bookmark_menu",
|
|
"@click": "onMenuToggle",
|
|
},
|
|
)
|
|
|
|
with context.push(
|
|
{
|
|
"theme": theme,
|
|
"bookmark": data.bookmark._asdict(),
|
|
"js": data.js,
|
|
"bookmark_icon_data": bookmark_icon_data,
|
|
}
|
|
):
|
|
return lazy_load_template(bookmark_template_str).render(context)
|
|
|
|
|
|
#####################################
|
|
# LIST
|
|
#####################################
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ListItem:
|
|
"""
|
|
Single menu item used with the `menu` components.
|
|
|
|
Menu items can be divided by a horizontal line to indicate that the items
|
|
belong together. In code, we specify this by wrapping the item(s) as an array.
|
|
"""
|
|
|
|
value: Any
|
|
"""Value of the menu item to render."""
|
|
|
|
link: Optional[str] = None
|
|
"""
|
|
If set, the list item will be wrapped in an `<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."""
|
|
|
|
|
|
list_template_str: 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 }}
|
|
{% endfor %}
|
|
</ul>
|
|
""" # noqa: E501
|
|
|
|
|
|
class ListData(NamedTuple):
|
|
items: List[ListItem]
|
|
attrs: Optional[dict] = None
|
|
item_attrs: Optional[dict] = None
|
|
# Slots
|
|
slot_empty: Optional[str] = None
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True, name="list")
|
|
def list_tag(context: Context, data: ListData):
|
|
with context.push(
|
|
{
|
|
"items": data.items,
|
|
"attrs": data.attrs,
|
|
"item_attrs": data.item_attrs,
|
|
"slot_empty": data.slot_empty,
|
|
}
|
|
):
|
|
return lazy_load_template(list_template_str).render(context)
|
|
|
|
|
|
#####################################
|
|
# TABS
|
|
#####################################
|
|
|
|
|
|
class TabEntry(NamedTuple):
|
|
header: str
|
|
content: str
|
|
disabled: bool = False
|
|
|
|
|
|
class TabStaticEntry(NamedTuple):
|
|
header: str
|
|
href: str
|
|
content: Optional[str]
|
|
disabled: bool = False
|
|
|
|
|
|
tabs_impl_template_str: types.django_html = """
|
|
<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|safe }}
|
|
</div>
|
|
{% endfor %}
|
|
</article>
|
|
</div>
|
|
<script>
|
|
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);
|
|
},
|
|
}));
|
|
});
|
|
</script>
|
|
</div>
|
|
""" # noqa: E501
|
|
|
|
|
|
class TabsImplData(NamedTuple):
|
|
tabs: List[TabEntry]
|
|
# Unique name to identify this tabs instance, so we can open/close the tabs
|
|
# based on the query params.
|
|
name: Optional[str] = None
|
|
attrs: Optional[dict] = None
|
|
header_attrs: Optional[dict] = None
|
|
content_attrs: Optional[dict] = None
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def tabs_impl(context: Context, data: TabsImplData):
|
|
with context.push(
|
|
{
|
|
"attrs": data.attrs,
|
|
"tabs": data.tabs,
|
|
"header_attrs": data.header_attrs,
|
|
"content_attrs": data.content_attrs,
|
|
"tabs_data": {"name": data.name},
|
|
"theme": theme,
|
|
}
|
|
):
|
|
return lazy_load_template(tabs_impl_template_str).render(context)
|
|
|
|
|
|
class TabsData(NamedTuple):
|
|
# Unique name to identify this tabs instance, so we can open/close the tabs
|
|
# based on the query params.
|
|
name: Optional[str] = None
|
|
attrs: Optional[dict] = None
|
|
header_attrs: Optional[dict] = None
|
|
content_attrs: Optional[dict] = None
|
|
slot_content: Optional[CallableSlot] = None
|
|
|
|
|
|
# This is an "API" component, meaning that it's designed to process
|
|
# user input provided as nested components. But after the input is
|
|
# processed, it delegates to an internal "implementation" component
|
|
# that actually renders the content.
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def tabs(context: Context, data: TabsData):
|
|
if not data.slot_content:
|
|
return ""
|
|
|
|
ProvidedData = NamedTuple("ProvidedData", [("tabs", List[TabEntry]), ("enabled", bool)])
|
|
collected_tabs: List[TabEntry] = []
|
|
provided_data = ProvidedData(tabs=collected_tabs, enabled=True)
|
|
|
|
with context.push({"_tabs": provided_data}):
|
|
data.slot_content.render(context)
|
|
|
|
# By the time we get here, all child TabItem components should have been
|
|
# rendered, and they should've populated the tabs list.
|
|
return tabs_impl(
|
|
context,
|
|
TabsImplData(
|
|
tabs=collected_tabs,
|
|
name=data.name,
|
|
attrs=data.attrs,
|
|
header_attrs=data.header_attrs,
|
|
content_attrs=data.content_attrs,
|
|
),
|
|
)
|
|
|
|
|
|
class TabItemData(NamedTuple):
|
|
header: str
|
|
disabled: bool = False
|
|
slot_content: Optional[str] = None
|
|
|
|
|
|
# Use this component to define individual tabs inside the default slot
|
|
# inside the `tab` component.
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def tab_item(context, data: TabItemData):
|
|
# Access the list of tabs registered for parent Tabs component
|
|
# This raises if we're not nested inside the Tabs component.
|
|
tab_ctx = context["_tabs"]
|
|
|
|
# We accessed the _tabs context, but we're inside ANOTHER TabItem
|
|
if not tab_ctx.enabled:
|
|
raise RuntimeError(
|
|
"Component 'tab_item' was called with no parent Tabs component. "
|
|
"Either wrap 'tab_item' in Tabs component, or check if the component "
|
|
"is not a descendant of another instance of 'tab_item'"
|
|
)
|
|
parent_tabs = tab_ctx.tabs
|
|
|
|
parent_tabs.append(
|
|
{
|
|
"header": data.header,
|
|
"disabled": data.disabled,
|
|
"content": mark_safe(data.slot_content or "").strip(),
|
|
}
|
|
)
|
|
return ""
|
|
|
|
|
|
tabs_static_template_str: types.django_html = """
|
|
<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>
|
|
"""
|
|
|
|
|
|
class TabsStaticData(NamedTuple):
|
|
tabs: List[TabStaticEntry]
|
|
tab_index: int = 0
|
|
hide_body: bool = False
|
|
attrs: Optional[dict] = None
|
|
header_attrs: Optional[dict] = None
|
|
content_attrs: Optional[dict] = None
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def tabs_static(context: Context, data: TabsStaticData):
|
|
selected_content = data.tabs[data.tab_index].content
|
|
|
|
tabs_data = []
|
|
for tab_index, tab in enumerate(data.tabs):
|
|
is_selectd = tab_index == data.tab_index
|
|
styling = {
|
|
"tab": "border-b-2 " + theme.tab_active if is_selectd else "",
|
|
"text": theme.tab_text_active if is_selectd else theme.tab_text_inactive,
|
|
}
|
|
tabs_data.append((tab, styling))
|
|
|
|
with context.push(
|
|
{
|
|
"attrs": data.attrs,
|
|
"tabs_data": tabs_data,
|
|
"header_attrs": data.header_attrs,
|
|
"content_attrs": data.content_attrs,
|
|
"hide_body": data.hide_body,
|
|
"selected_content": selected_content,
|
|
"theme": theme,
|
|
}
|
|
):
|
|
return lazy_load_template(tabs_static_template_str).render(context)
|
|
|
|
|
|
#####################################
|
|
# PROJECT_INFO
|
|
#####################################
|
|
|
|
|
|
class ProjectInfoEntry(NamedTuple):
|
|
title: str
|
|
value: str
|
|
|
|
|
|
project_info_template_str: types.django_html = """
|
|
<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 %}
|
|
{% button edit_project_button_data %}
|
|
{% 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 #}
|
|
{% project_status_updates status_updates_data %}
|
|
<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 %}
|
|
{% button edit_team_button_data %}
|
|
{% endif %}
|
|
</div>
|
|
|
|
{% project_users project_users_data %}
|
|
</div>
|
|
|
|
{# Contacts section #}
|
|
<div>
|
|
<div class="flex justify-between items-start max-xl:mt-6">
|
|
<h3 class="mt-0">Contacts</h3>
|
|
|
|
{% if editable %}
|
|
{% button edit_contacts_button_data %}
|
|
{% 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>
|
|
{% icon link_icon_data %}
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</table>
|
|
{% else %}
|
|
<p class="text-sm italic">No entries</p>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
"""
|
|
|
|
|
|
class ProjectInfoData(NamedTuple):
|
|
project: Project
|
|
project_tags: List[str]
|
|
contacts: List[ProjectContact]
|
|
status_updates: List[ProjectStatusUpdate]
|
|
roles_with_users: List[ProjectRole]
|
|
editable: bool
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def project_info(context: Context, data: ProjectInfoData) -> str:
|
|
project_edit_url = f"/edit/{data.project['id']}/"
|
|
edit_project_roles_url = f"/edit/{data.project['id']}/roles/"
|
|
edit_contacts_url = f"/edit/{data.project['id']}/contacts/"
|
|
create_status_update_url = f"/create/{data.project['id']}/status_update/"
|
|
|
|
contacts_data = [
|
|
{
|
|
"contact": contact,
|
|
"link_icon_data": IconData(
|
|
href=f"/contacts/{contact['link_id']}",
|
|
name="arrow-top-right-on-square",
|
|
variant="outline",
|
|
color="text-gray-400 hover:text-gray-500",
|
|
),
|
|
}
|
|
for contact in data.contacts
|
|
]
|
|
|
|
project_info = [
|
|
ProjectInfoEntry("Org", data.project["organization"]["name"]),
|
|
ProjectInfoEntry("Duration", f"{data.project['start_date']} - {data.project['end_date']}"),
|
|
ProjectInfoEntry("Status", data.project["status"]),
|
|
ProjectInfoEntry("Tags", ", ".join(data.project_tags) or "-"),
|
|
]
|
|
|
|
edit_project_button_data = ButtonData(
|
|
href=project_edit_url,
|
|
attrs={"class": "not-prose"},
|
|
slot_content="Edit Project",
|
|
)
|
|
|
|
edit_team_button_data = ButtonData(
|
|
href=edit_project_roles_url,
|
|
attrs={"class": "not-prose"},
|
|
slot_content="Edit Team",
|
|
)
|
|
|
|
edit_contacts_button_data = ButtonData(
|
|
href=edit_contacts_url,
|
|
attrs={"class": "not-prose"},
|
|
slot_content="Edit Contacts",
|
|
)
|
|
|
|
status_updates_data = ProjectStatusUpdatesData(
|
|
project_id=data.project["id"],
|
|
status_updates=data.status_updates,
|
|
editable=data.editable,
|
|
)
|
|
|
|
project_users_data = ProjectUsersData(
|
|
project_id=data.project["id"],
|
|
roles_with_users=data.roles_with_users,
|
|
available_roles=None,
|
|
available_users=None,
|
|
editable=False,
|
|
)
|
|
|
|
with context.push(
|
|
{
|
|
"project_edit_url": project_edit_url,
|
|
"edit_contacts_url": edit_contacts_url,
|
|
"edit_project_roles_url": edit_project_roles_url,
|
|
"create_status_update_url": create_status_update_url,
|
|
"contacts_data": contacts_data,
|
|
"project": data.project,
|
|
"roles_with_users": data.roles_with_users,
|
|
"project_info": project_info,
|
|
"status_updates": data.status_updates,
|
|
"editable": data.editable,
|
|
"status_updates_data": status_updates_data,
|
|
"project_users_data": project_users_data,
|
|
"edit_project_button_data": edit_project_button_data,
|
|
"edit_team_button_data": edit_team_button_data,
|
|
"edit_contacts_button_data": edit_contacts_button_data,
|
|
}
|
|
):
|
|
return lazy_load_template(project_info_template_str).render(context)
|
|
|
|
|
|
#####################################
|
|
# PROJECT_NOTES
|
|
#####################################
|
|
|
|
project_notes_template_str: types.django_html = """
|
|
<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 %}
|
|
{% icon note.edit_note_icon_data %}
|
|
{% 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 %}
|
|
{% icon comment.edit_comment_icon_data %}
|
|
{% endif %}
|
|
</div>
|
|
<div class="flex-auto">
|
|
<p class="my-0">
|
|
{{ comment.text }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
|
|
<div class="text-right">
|
|
{% if editable %}
|
|
{% button note.create_comment_button_data %}
|
|
{% endif %}
|
|
</div>
|
|
</details>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if editable %}
|
|
{% button create_note_button_data %}
|
|
{% endif %}
|
|
</div>
|
|
"""
|
|
|
|
|
|
def _make_comments_data(note: ProjectNote, comment: ProjectNoteComment):
|
|
modified_time_str = format_timestamp(datetime.fromisoformat(comment["modified"]))
|
|
formatted_modified_by = (
|
|
modified_time_str
|
|
+ " "
|
|
+ comment['modified_by']['name']
|
|
)
|
|
|
|
edit_comment_icon_data = IconData(
|
|
name="pencil-square",
|
|
variant="outline",
|
|
href=f"/update/{note['project']['id']}/note/{note['id']}/comment/{comment['id']}/",
|
|
color="text-gray-400 hover:text-gray-500",
|
|
)
|
|
|
|
return {
|
|
"timestamp": formatted_modified_by,
|
|
"notes": comment["text"],
|
|
"edit_comment_icon_data": edit_comment_icon_data,
|
|
}
|
|
|
|
|
|
def _make_notes_data(
|
|
notes: List[ProjectNote],
|
|
comments_by_notes: Dict[int, List[ProjectNoteComment]],
|
|
):
|
|
notes_data: List[dict] = []
|
|
for note in notes:
|
|
comments = comments_by_notes.get(note["id"], [])
|
|
comments_data = [_make_comments_data(note, comment) for comment in comments]
|
|
|
|
edit_note_icon_data = IconData(
|
|
name="pencil-square",
|
|
variant="outline",
|
|
href=f"/edit/{note['project']['id']}/note/{note['id']}/",
|
|
color="text-gray-400 hover:text-gray-500",
|
|
)
|
|
|
|
create_comment_button_data = ButtonData(
|
|
href=f"/create/{note['project']['id']}/note/{note['id']}/",
|
|
slot_content="Add comment",
|
|
)
|
|
|
|
notes_data.append(
|
|
{
|
|
"text": note["text"],
|
|
"timestamp": note["created"],
|
|
"edit_note_icon_data": edit_note_icon_data,
|
|
"comments": comments_data,
|
|
"create_comment_button_data": create_comment_button_data,
|
|
}
|
|
)
|
|
|
|
return notes_data
|
|
|
|
|
|
class ProjectNotesData(NamedTuple):
|
|
project_id: int
|
|
notes: List[ProjectNote]
|
|
comments_by_notes: Dict[int, List[ProjectNoteComment]]
|
|
editable: bool
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def project_notes(context: Context, data: ProjectNotesData) -> str:
|
|
notes_data = _make_notes_data(data.notes, data.comments_by_notes)
|
|
|
|
create_note_button_data = ButtonData(
|
|
href=f"/create/{data.project_id}/note/",
|
|
slot_content="Add Note",
|
|
)
|
|
|
|
with context.push(
|
|
{
|
|
"create_note_button_data": create_note_button_data,
|
|
"notes_data": notes_data,
|
|
"editable": data.editable,
|
|
}
|
|
):
|
|
return lazy_load_template(project_notes_template_str).render(context)
|
|
|
|
|
|
#####################################
|
|
# PROJECT_OUTPUTS_SUMMARY
|
|
#####################################
|
|
|
|
|
|
class AttachmentWithTags(NamedTuple):
|
|
attachment: ProjectOutputAttachment
|
|
tags: List[str]
|
|
|
|
|
|
class OutputWithAttachments(NamedTuple):
|
|
output: ProjectOutput
|
|
attachments: List[AttachmentWithTags]
|
|
|
|
|
|
class OutputWithAttachmentsAndDeps(NamedTuple):
|
|
output: ProjectOutput
|
|
attachments: List[AttachmentWithTags]
|
|
dependencies: List[OutputWithAttachments]
|
|
|
|
|
|
outputs_summary_expansion_content_template_str: types.django_html = """
|
|
{% if outputs %}
|
|
{% project_outputs outputs_data %}
|
|
{% else %}
|
|
No outputs
|
|
{% endif %}
|
|
"""
|
|
|
|
project_outputs_summary_template_str: types.django_html = """
|
|
<div class="flex flex-col gap-y-3">
|
|
{% for group in groups %}
|
|
{% expansion_panel group %}
|
|
{% endfor %}
|
|
</div>
|
|
"""
|
|
|
|
|
|
class ProjectOutputsSummaryData(NamedTuple):
|
|
project_id: int
|
|
outputs: List["OutputWithAttachmentsAndDeps"]
|
|
editable: bool
|
|
phase_titles: Dict[ProjectPhaseType, str]
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def project_outputs_summary(context: Context, data: ProjectOutputsSummaryData) -> str:
|
|
outputs_by_phase = group_by(data.outputs, lambda output, _: output[0]["phase"]["phase_template"]["type"])
|
|
|
|
groups: List[ExpansionPanelData] = []
|
|
for phase_meta in PROJECT_PHASES_META.values():
|
|
phase_outputs = outputs_by_phase.get(phase_meta.type, [])
|
|
title = data.phase_titles[phase_meta.type]
|
|
has_outputs = bool(phase_outputs)
|
|
|
|
outputs_data = ProjectOutputsData(
|
|
outputs=phase_outputs,
|
|
project_id=data.project_id,
|
|
editable=data.editable,
|
|
)
|
|
|
|
with context.push(
|
|
{
|
|
"outputs_data": outputs_data,
|
|
"outputs": data.outputs,
|
|
}
|
|
):
|
|
expansion_panel_content = lazy_load_template(outputs_summary_expansion_content_template_str).render(context) # noqa: E501
|
|
|
|
expansion_panel_data = ExpansionPanelData(
|
|
open=has_outputs,
|
|
header_attrs={"class": "flex gap-x-2 prose"},
|
|
slot_header=f"""
|
|
<h3 class="m-0">
|
|
{title}
|
|
</h3>
|
|
""",
|
|
slot_content=expansion_panel_content,
|
|
)
|
|
|
|
groups.append(expansion_panel_data)
|
|
|
|
with context.push(
|
|
{
|
|
"groups": groups,
|
|
}
|
|
):
|
|
return lazy_load_template(project_outputs_summary_template_str).render(context)
|
|
|
|
|
|
#####################################
|
|
# PROJECT_STATUS_UPDATES
|
|
#####################################
|
|
|
|
project_status_updates_template_str: types.django_html = """
|
|
<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 %}
|
|
{% button add_status_button_data %}
|
|
{% endif %}
|
|
</div>
|
|
{% if updates_data %}
|
|
<div class="mt-8">
|
|
{% for update, update_icon_data 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 %}
|
|
{% icon update_icon_data %}
|
|
{% endif %}
|
|
</div>
|
|
<p class="my-0 text-gray-900">
|
|
{{ update.text }}
|
|
</p>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
"""
|
|
|
|
|
|
def _make_status_update_data(status_update: ProjectStatusUpdate):
|
|
modified_time_str = format_timestamp(datetime.fromisoformat(status_update["modified"]))
|
|
formatted_modified_by = (
|
|
modified_time_str
|
|
+ " "
|
|
+ status_update['modified_by']['name']
|
|
)
|
|
|
|
return {
|
|
"timestamp": formatted_modified_by,
|
|
"text": status_update["text"],
|
|
"edit_href": f"/edit/{status_update['project']['id']}/status_update/{status_update['id']}",
|
|
}
|
|
|
|
|
|
class ProjectStatusUpdatesData(NamedTuple):
|
|
project_id: int
|
|
status_updates: List[ProjectStatusUpdate]
|
|
editable: bool
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def project_status_updates(context: Context, data: ProjectStatusUpdatesData) -> str:
|
|
create_status_update_url = f"/create/{data.project_id}/status_update"
|
|
|
|
updates_data = [_make_status_update_data(status_update) for status_update in data.status_updates]
|
|
updates_data = [
|
|
(
|
|
entry,
|
|
IconData(
|
|
name="pencil-square",
|
|
variant="outline",
|
|
href=entry["edit_href"],
|
|
color="text-gray-400 hover:text-gray-500",
|
|
),
|
|
)
|
|
for entry in updates_data
|
|
]
|
|
|
|
add_status_button_data = ButtonData(
|
|
href=create_status_update_url,
|
|
slot_content="Add status update",
|
|
)
|
|
|
|
with context.push(
|
|
{
|
|
"create_status_update_url": create_status_update_url,
|
|
"updates_data": updates_data,
|
|
"editable": data.editable,
|
|
"add_status_button_data": add_status_button_data,
|
|
}
|
|
):
|
|
return lazy_load_template(project_status_updates_template_str).render(context)
|
|
|
|
|
|
#####################################
|
|
# PROJECT USERS
|
|
#####################################
|
|
|
|
roles_table_headers = [
|
|
TableHeader(key="name", name="Name"),
|
|
TableHeader(key="role", name="Role"),
|
|
TableHeader(key="delete", name="", hidden=True),
|
|
]
|
|
|
|
|
|
class ProjectAddUserForm(ConditionalEditForm):
|
|
user_id = forms.ChoiceField(required=True, choices=[], label="User")
|
|
role = forms.ChoiceField(required=True, choices=[])
|
|
|
|
def __init__(
|
|
self,
|
|
editable: bool,
|
|
available_role_choices: List[Tuple[str, str]],
|
|
available_user_choices: List[Tuple[str, str]],
|
|
*args,
|
|
**kwargs,
|
|
):
|
|
self.editable = editable
|
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
user_field: forms.ChoiceField = self.fields["user_id"] # type: ignore[assignment]
|
|
user_field.choices = available_user_choices
|
|
|
|
role_field: forms.ChoiceField = self.fields["role"] # type: ignore[assignment]
|
|
role_field.choices = available_role_choices
|
|
|
|
|
|
user_dialog_title_template_str: types.django_html = """
|
|
<div class="flex">
|
|
<span>
|
|
Remove
|
|
<span x-text="role && role.user_name"></span>
|
|
from this project?
|
|
</span>
|
|
{% icon delete_icon_data %}
|
|
</div>
|
|
"""
|
|
|
|
project_users_template_str: types.django_html = """
|
|
<div x-data="project_users">
|
|
{% if table_rows %}
|
|
{% table table_data %}
|
|
{% 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>
|
|
|
|
{% button set_role_button_data %}
|
|
{% button cancel_button_data %}
|
|
</form>
|
|
|
|
<template x-if="role && isDeleteDialogOpen">
|
|
{% dialog dialog_data %}
|
|
</template>
|
|
{% endif %}
|
|
<script>
|
|
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;
|
|
},
|
|
}));
|
|
});
|
|
</script>
|
|
</div>
|
|
"""
|
|
|
|
|
|
class ProjectUsersData(NamedTuple):
|
|
project_id: int
|
|
roles_with_users: List[ProjectRole]
|
|
available_roles: Optional[List[str]]
|
|
available_users: Optional[List[User]]
|
|
editable: bool = False
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def project_users(context: Context, data: ProjectUsersData) -> str:
|
|
roles_table_rows = []
|
|
for role in data.roles_with_users:
|
|
user = role["user"]
|
|
|
|
if data.editable:
|
|
delete_action = project_user_action(
|
|
context,
|
|
ProjectUserActionData(
|
|
project_id=data.project_id,
|
|
role_id=role["id"],
|
|
user_name=user["name"],
|
|
),
|
|
)
|
|
else:
|
|
delete_action = ""
|
|
|
|
roles_table_rows.append(
|
|
create_table_row(
|
|
cols={
|
|
"name": TableCell(user["name"]),
|
|
"role": TableCell(role["name"]),
|
|
"delete": delete_action,
|
|
}
|
|
)
|
|
)
|
|
|
|
submit_url = f"/submit/{data.project_id}/role/create"
|
|
project_url = f"/project/{data.project_id}"
|
|
|
|
if data.available_roles:
|
|
available_role_choices = [(role, role) for role in data.available_roles]
|
|
else:
|
|
available_role_choices = []
|
|
|
|
if data.available_users:
|
|
available_user_choices = [(str(user["id"]), user["name"]) for user in data.available_users]
|
|
else:
|
|
available_user_choices = []
|
|
|
|
table_data = TableData(
|
|
headers=roles_table_headers,
|
|
rows=roles_table_rows,
|
|
attrs={"@user_delete": "onUserDelete"},
|
|
)
|
|
|
|
set_role_button_data = ButtonData(
|
|
type="submit",
|
|
slot_content="Set role",
|
|
)
|
|
|
|
cancel_button_data = ButtonData(
|
|
href=project_url,
|
|
variant="secondary",
|
|
slot_content="Go back",
|
|
)
|
|
|
|
delete_icon_data = IconData(
|
|
name="trash",
|
|
variant="outline",
|
|
size=18,
|
|
attrs={"class": "p-2 self-center"},
|
|
)
|
|
|
|
with context.push(
|
|
{
|
|
"delete_icon_data": delete_icon_data,
|
|
}
|
|
):
|
|
user_dialog_title = lazy_load_template(user_dialog_title_template_str).render(context)
|
|
|
|
dialog_data = DialogData(
|
|
model="isDeleteDialogOpen",
|
|
confirm_text="Delete",
|
|
confirm_href="#",
|
|
confirm_color="error",
|
|
confirm_attrs={":href": "role.delete_url"},
|
|
content_attrs={"class": "w-full"},
|
|
slot_content="<div>This action cannot be undone.</div>",
|
|
slot_title=user_dialog_title,
|
|
)
|
|
|
|
with context.push(
|
|
{
|
|
"editable": data.editable,
|
|
"table_headers": roles_table_headers,
|
|
"table_rows": roles_table_rows,
|
|
"add_user_form": ProjectAddUserForm(
|
|
data.editable,
|
|
available_role_choices,
|
|
available_user_choices,
|
|
),
|
|
"submit_url": submit_url,
|
|
"project_url": project_url,
|
|
"table_data": table_data,
|
|
"set_role_button_data": set_role_button_data,
|
|
"cancel_button_data": cancel_button_data,
|
|
"dialog_data": dialog_data,
|
|
}
|
|
):
|
|
return lazy_load_template(project_users_template_str).render(context)
|
|
|
|
|
|
#####################################
|
|
# PROJECT_USER_ACTION
|
|
#####################################
|
|
|
|
project_user_action_template_str: types.django_html = """
|
|
<div x-data="{
|
|
role: {{ role | alpine }},
|
|
}">
|
|
{% icon delete_icon_data %}
|
|
</div>
|
|
"""
|
|
|
|
|
|
class ProjectUserActionData(NamedTuple):
|
|
project_id: int
|
|
role_id: int
|
|
user_name: str
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def project_user_action(context: Context, data: ProjectUserActionData) -> str:
|
|
delete_url = f"/delete/{data.project_id}/{data.role_id}"
|
|
|
|
role_data = {
|
|
"delete_url": delete_url,
|
|
"role_id": data.role_id,
|
|
"user_name": data.user_name,
|
|
}
|
|
|
|
delete_icon_data = IconData(
|
|
name="trash",
|
|
variant="outline",
|
|
size=18,
|
|
href="#",
|
|
color="text-gray-500 hover:text-gray-400",
|
|
svg_attrs={"class": "inline mb-1"},
|
|
attrs={
|
|
"class": "p-2",
|
|
"@click.stop": "$dispatch('user_delete', { role })",
|
|
},
|
|
)
|
|
|
|
with context.push(
|
|
{
|
|
"role": role_data,
|
|
"delete_icon_data": delete_icon_data,
|
|
}
|
|
):
|
|
return lazy_load_template(project_user_action_template_str).render(context)
|
|
|
|
|
|
#####################################
|
|
# PROJECT_OUTPUTS
|
|
#####################################
|
|
|
|
output_expansion_panel_content_template_str: types.django_html = """
|
|
<div>
|
|
{# Dependencies #}
|
|
{% for dep_data in dependencies_data %}
|
|
{% project_output_dependency dep_data %}
|
|
{% endfor %}
|
|
|
|
{# Own data + attachments #}
|
|
{% project_output_form output_form_data %}
|
|
</div>
|
|
"""
|
|
|
|
project_outputs_template_str: types.django_html = """
|
|
<div class="flex flex-col">
|
|
{% for data, output_badge_data, expansion_panel_data in outputs_data %}
|
|
<div class="flex gap-x-3">
|
|
<div>
|
|
{% project_output_badge output_badge_data %}
|
|
</div>
|
|
<div class="w-full">
|
|
{% expansion_panel expansion_panel_data %}
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
"""
|
|
|
|
|
|
class ProjectOutputsData(NamedTuple):
|
|
project_id: int
|
|
outputs: List[OutputWithAttachmentsAndDeps]
|
|
editable: bool
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def project_outputs(context: Context, data: ProjectOutputsData) -> str:
|
|
outputs_data: List[Tuple[RenderedProjectOutput, ProjectOutputBadgeData, ExpansionPanelData]] = []
|
|
for output_tuple in data.outputs:
|
|
output, attachments, dependencies = output_tuple
|
|
|
|
attach_data: List[RenderedAttachment] = []
|
|
for attachment in attachments:
|
|
attach_data.append(
|
|
RenderedAttachment(
|
|
url=attachment[0]["url"],
|
|
text=attachment[0]["text"],
|
|
tags=attachment[1],
|
|
)
|
|
)
|
|
|
|
update_output_url = "/update"
|
|
|
|
deps: List[RenderedOutputDep] = []
|
|
for dep in dependencies:
|
|
output, attachments = dep
|
|
phase_url = f"/phase/{data.project_id}/{output['phase']['phase_template']['type']}"
|
|
deps.append(
|
|
RenderedOutputDep(
|
|
dependency=dep,
|
|
phase_url=phase_url,
|
|
attachments=[
|
|
{
|
|
"url": d.attachment["url"],
|
|
"text": d.attachment["text"],
|
|
"tags": d.tags,
|
|
}
|
|
for d in attachments
|
|
],
|
|
)
|
|
)
|
|
|
|
has_missing_deps = any([not output["completed"] for output, _ in dependencies])
|
|
|
|
output_badge_data = ProjectOutputBadgeData(
|
|
completed=output["completed"],
|
|
missing_deps=has_missing_deps,
|
|
)
|
|
|
|
output_data = RenderedProjectOutput(
|
|
output=output,
|
|
dependencies=deps,
|
|
has_missing_deps=has_missing_deps,
|
|
output_data={
|
|
"editable": data.editable,
|
|
},
|
|
attachments=attach_data,
|
|
update_output_url=update_output_url,
|
|
)
|
|
output_form_data = ProjectOutputFormData(
|
|
data=output_data,
|
|
editable=data.editable,
|
|
)
|
|
with context.push(
|
|
{
|
|
"dependencies_data": [ProjectOutputDependencyData(dependency=dep) for dep in deps],
|
|
"output_form_data": output_form_data,
|
|
}
|
|
):
|
|
output_expansion_panel_content = lazy_load_template(output_expansion_panel_content_template_str).render(context) # noqa: E501
|
|
|
|
expansion_panel_data = ExpansionPanelData(
|
|
panel_id=output["id"], # type: ignore[arg-type]
|
|
icon_position="right",
|
|
attrs={"class": "border-b border-solid border-gray-300 pb-2 mb-3"},
|
|
header_attrs={"class": "flex align-center justify-between"},
|
|
slot_header=f"""
|
|
<div>
|
|
{output['name']}
|
|
</div>
|
|
""",
|
|
slot_content=output_expansion_panel_content,
|
|
)
|
|
|
|
outputs_data.append(
|
|
(
|
|
output_data,
|
|
output_badge_data,
|
|
expansion_panel_data,
|
|
)
|
|
)
|
|
|
|
with context.push(
|
|
{
|
|
"outputs_data": outputs_data,
|
|
}
|
|
):
|
|
return lazy_load_template(project_outputs_template_str).render(context)
|
|
|
|
|
|
#####################################
|
|
# PROJECT_OUTPUT_BADGE
|
|
#####################################
|
|
|
|
project_output_badge_template_str: types.django_html = """
|
|
<span class="flex h-9 items-center">
|
|
{# Missing dependencies #}
|
|
{% if missing_deps %}
|
|
{% icon missing_icon_data %}
|
|
|
|
{# Completed #}
|
|
{% elif completed %}
|
|
<span class="relative z-10 flex h-8 w-8 items-center justify-center rounded-full {{ theme.check_interactive }}">
|
|
{% icon completed_icon_data %}
|
|
</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
|
|
|
|
|
|
class ProjectOutputBadgeData(NamedTuple):
|
|
completed: bool
|
|
missing_deps: bool
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def project_output_badge(context: Context, data: ProjectOutputBadgeData):
|
|
missing_icon_data = IconData(
|
|
name="exclamation-triangle",
|
|
variant="outline",
|
|
color="text-black",
|
|
size=32,
|
|
stroke_width=2,
|
|
attrs={"title": "A dependent dependency has not been met!"},
|
|
)
|
|
|
|
completed_icon_data = IconData(
|
|
name="check",
|
|
variant="outline",
|
|
color="text-white",
|
|
size=20,
|
|
stroke_width=2,
|
|
attrs={"class": "p-2"},
|
|
)
|
|
|
|
with context.push(
|
|
{
|
|
"completed": data.completed,
|
|
"missing_deps": data.missing_deps,
|
|
"theme": theme,
|
|
"missing_icon_data": missing_icon_data,
|
|
"completed_icon_data": completed_icon_data,
|
|
}
|
|
):
|
|
return lazy_load_template(project_output_badge_template_str).render(context)
|
|
|
|
|
|
#####################################
|
|
# PROJECT_OUTPUT_DEPENDENCY
|
|
#####################################
|
|
|
|
project_output_dependency_template_str: types.django_html = """
|
|
<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">
|
|
{% icon warning_icon_data %}
|
|
|
|
Missing '{{ dependency.output.name }}' from
|
|
|
|
{% button missing_button_data %}
|
|
</span>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{# Attachments of parent dependencies #}
|
|
{% project_output_attachments parent_attachments_data %}
|
|
<script>
|
|
// 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);
|
|
});
|
|
</script>
|
|
</div>
|
|
"""
|
|
|
|
|
|
class ProjectOutputDependencyData(NamedTuple):
|
|
dependency: "RenderedOutputDep"
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def project_output_dependency(context: Context, data: ProjectOutputDependencyData):
|
|
warning_icon_data = IconData(
|
|
name="exclamation-triangle",
|
|
variant="outline",
|
|
size=24,
|
|
stroke_width=2,
|
|
color="text-gray-500",
|
|
attrs={"class": "float-left pr-1"},
|
|
)
|
|
|
|
missing_button_data = ButtonData(
|
|
variant="plain",
|
|
href=data.dependency.phase_url,
|
|
attrs={
|
|
"target": "_blank",
|
|
"class": "hover:text-gray-600 !underline",
|
|
},
|
|
slot_content=title(data.dependency.dependency[0]["phase"]["phase_template"]["type"]),
|
|
)
|
|
|
|
parent_attachments_data = ProjectOutputAttachmentsData(
|
|
editable=False,
|
|
has_attachments=bool(data.dependency.attachments),
|
|
js_props={"attachments": "attachments.value"},
|
|
)
|
|
|
|
with context.push(
|
|
{
|
|
"attachments": data.dependency.attachments,
|
|
"dependency": data.dependency.dependency,
|
|
"phase_url": data.dependency.phase_url,
|
|
"OUTPUT_DESCRIPTION_PLACEHOLDER": OUTPUT_DESCRIPTION_PLACEHOLDER,
|
|
"warning_icon_data": warning_icon_data,
|
|
"missing_button_data": missing_button_data,
|
|
"parent_attachments_data": parent_attachments_data,
|
|
}
|
|
):
|
|
return lazy_load_template(project_output_dependency_template_str).render(context)
|
|
|
|
|
|
#####################################
|
|
# PROJECT_OUTPUT_ATTACHMENTS
|
|
#####################################
|
|
|
|
|
|
class ProjectOutputAttachmentsJsProps(TypedDict):
|
|
attachments: str
|
|
|
|
|
|
project_output_attachments_template_str: types.django_html = """
|
|
<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">
|
|
{% button attachment_preview_button_data %}
|
|
</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>
|
|
{% button edit_button_data %}
|
|
</div>
|
|
|
|
<div>
|
|
{% button remove_button_data %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{% tags tags_data %}
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<script>
|
|
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);
|
|
});
|
|
</script>
|
|
"""
|
|
|
|
|
|
class ProjectOutputAttachmentsData(NamedTuple):
|
|
has_attachments: bool
|
|
js_props: ProjectOutputAttachmentsJsProps
|
|
editable: bool
|
|
attrs: Optional[dict] = None
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def project_output_attachments(context: Context, data: ProjectOutputAttachmentsData):
|
|
attachment_preview_button_data = ButtonData(
|
|
variant="plain",
|
|
link=True,
|
|
attrs={
|
|
"x-bind:href": "attachment.url",
|
|
"x-text": "attachment.text",
|
|
"target": "_blank",
|
|
"class": "hover:text-gray-600 !underline",
|
|
"style": "color: cornflowerblue;",
|
|
},
|
|
)
|
|
|
|
edit_button_data = ButtonData(
|
|
attrs={
|
|
"class": "!py-1",
|
|
"x-text": "attachment.isPreview ? 'Edit' : 'Preview'",
|
|
"@click": "() => $emit('toggleAttachment', index)",
|
|
},
|
|
slot_content="Edit",
|
|
)
|
|
|
|
remove_button_data = ButtonData(
|
|
color="error",
|
|
attrs={
|
|
"class": "!py-1",
|
|
"@click": "() => $emit('removeAttachment', index)",
|
|
},
|
|
slot_content="Remove",
|
|
)
|
|
|
|
tags_data = TagsData(
|
|
tag_type="project_output_attachment",
|
|
editable=data.editable,
|
|
js_props={
|
|
"initTags": "attachment.tags",
|
|
"onChange": "(tags) => $emit('setAttachmentTags', index, tags)",
|
|
},
|
|
attrs={
|
|
"class": "pb-8",
|
|
},
|
|
)
|
|
|
|
with context.push(
|
|
{
|
|
"has_attachments": data.has_attachments,
|
|
"editable": data.editable,
|
|
"attrs": data.attrs,
|
|
"js_props": data.js_props,
|
|
"text_max_len": FORM_SHORT_TEXT_MAX_LEN,
|
|
"attachment_preview_button_data": attachment_preview_button_data,
|
|
"edit_button_data": edit_button_data,
|
|
"remove_button_data": remove_button_data,
|
|
"tags_data": tags_data,
|
|
}
|
|
):
|
|
return lazy_load_template(project_output_attachments_template_str).render(context)
|
|
|
|
|
|
#####################################
|
|
# PROJECT_OUTPUT_FORM
|
|
#####################################
|
|
|
|
OUTPUT_DESCRIPTION_PLACEHOLDER = "Placeholder text"
|
|
|
|
|
|
class RenderedAttachment(NamedTuple):
|
|
url: str
|
|
text: str
|
|
tags: List[str]
|
|
|
|
|
|
class RenderedOutputDep(NamedTuple):
|
|
dependency: OutputWithAttachments
|
|
phase_url: str
|
|
attachments: List[dict]
|
|
|
|
|
|
class RenderedProjectOutput(NamedTuple):
|
|
output: ProjectOutput
|
|
dependencies: List[RenderedOutputDep]
|
|
has_missing_deps: bool
|
|
output_data: dict
|
|
attachments: List[RenderedAttachment]
|
|
update_output_url: str
|
|
|
|
|
|
form_content_template_str: types.django_html = """
|
|
{# Output description - editable #}
|
|
{% if editable %}
|
|
<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 %}
|
|
{% button add_attachment_button_data %}
|
|
|
|
{% button save_button_data %}
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
{% project_output_attachments project_output_attachments_data %}
|
|
"""
|
|
|
|
project_output_form_template_str: types.django_html = """
|
|
<div
|
|
x-data="project_output_form"
|
|
x-props="{
|
|
initAttachments: '{{ alpine_attachments|json|escape }}'
|
|
}"
|
|
>
|
|
{% form form_data %}
|
|
</div>
|
|
<script>
|
|
// 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);
|
|
});
|
|
</script>
|
|
"""
|
|
|
|
|
|
class ProjectOutputFormData(NamedTuple):
|
|
data: RenderedProjectOutput
|
|
editable: bool
|
|
|
|
|
|
@registry.library.simple_tag(takes_context=True)
|
|
def project_output_form(context: Context, data: ProjectOutputFormData):
|
|
project_output_attachments_data = ProjectOutputAttachmentsData(
|
|
has_attachments=bool(data.data.attachments),
|
|
editable=data.editable,
|
|
js_props={
|
|
"attachments": "attachments.value",
|
|
"onToggleAttachment": "(index) => toggleAttachmentPreview(index)",
|
|
"onSetAttachmentTags": "(index, tags) => setAttachmentTags(index, tags)",
|
|
"onUpdateAttachmentData": "(index, data) => updateAttachmentData(index, data)",
|
|
"onRemoveAttachment": "(index) => removeAttachment(index)",
|
|
}, # type: ignore[typeddict-unknown-key]
|
|
)
|
|
|
|
save_button_data = ButtonData(
|
|
attrs={
|
|
"@click": "onOutputSubmit({ reload: true })",
|
|
},
|
|
slot_content="Save",
|
|
)
|
|
|
|
add_attachment_button_data = ButtonData(
|
|
variant="secondary",
|
|
attrs={"@click": "addAttachment"},
|
|
slot_content="Add attachment",
|
|
)
|
|
|
|
with context.push(
|
|
{
|
|
"data": data.data,
|
|
"editable": data.editable,
|
|
"OUTPUT_DESCRIPTION_PLACEHOLDER": OUTPUT_DESCRIPTION_PLACEHOLDER,
|
|
"project_output_attachments_data": project_output_attachments_data,
|
|
"save_button_data": save_button_data,
|
|
"add_attachment_button_data": add_attachment_button_data,
|
|
}
|
|
):
|
|
form_content = lazy_load_template(form_content_template_str).render(context)
|
|
|
|
form_data = FormData(
|
|
submit_href=data.data.update_output_url,
|
|
actions_hide=True,
|
|
slot_form=form_content,
|
|
)
|
|
|
|
with context.push(
|
|
{
|
|
"form_data": form_data,
|
|
"alpine_attachments": [d._asdict() for d in data.data.attachments],
|
|
}
|
|
):
|
|
return lazy_load_template(project_output_form_template_str).render(context)
|
|
|
|
#####################################
|
|
#
|
|
# IMPLEMENTATION END
|
|
#
|
|
#####################################
|
|
|
|
|
|
# DO NOT REMOVE - See https://github.com/django-components/django-components/pull/999
|
|
# ----------- TESTS START ------------ #
|
|
# The code above is used also used when benchmarking.
|
|
# The section below is NOT included.
|
|
|
|
from django_components.testing import djc_test # noqa: E402
|
|
|
|
@djc_test
|
|
def test_render(snapshot):
|
|
data = gen_render_data()
|
|
rendered = render(data)
|
|
assert rendered == snapshot
|