polyadmin 0.1.0b1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. polyadmin/__init__.py +47 -0
  2. polyadmin/core/__init__.py +0 -0
  3. polyadmin/core/_async.py +21 -0
  4. polyadmin/core/action.py +135 -0
  5. polyadmin/core/admin.py +129 -0
  6. polyadmin/core/audit.py +65 -0
  7. polyadmin/core/auth.py +52 -0
  8. polyadmin/core/authorization.py +48 -0
  9. polyadmin/core/csrf.py +70 -0
  10. polyadmin/core/dashboard.py +41 -0
  11. polyadmin/core/delete.py +146 -0
  12. polyadmin/core/exporter.py +136 -0
  13. polyadmin/core/field.py +201 -0
  14. polyadmin/core/filter.py +305 -0
  15. polyadmin/core/inline.py +97 -0
  16. polyadmin/core/login.py +110 -0
  17. polyadmin/core/model_admin.py +349 -0
  18. polyadmin/core/page.py +62 -0
  19. polyadmin/core/pagination.py +70 -0
  20. polyadmin/core/query.py +243 -0
  21. polyadmin/core/relation.py +40 -0
  22. polyadmin/core/slug.py +57 -0
  23. polyadmin/core/template_context.py +679 -0
  24. polyadmin/core/widget.py +280 -0
  25. polyadmin/fastapi/__init__.py +3 -0
  26. polyadmin/fastapi/audit.py +52 -0
  27. polyadmin/fastapi/auth.py +112 -0
  28. polyadmin/fastapi/csrf.py +93 -0
  29. polyadmin/fastapi/deletes.py +76 -0
  30. polyadmin/fastapi/errors.py +93 -0
  31. polyadmin/fastapi/handlers.py +798 -0
  32. polyadmin/fastapi/inlines.py +177 -0
  33. polyadmin/fastapi/locale.py +128 -0
  34. polyadmin/fastapi/login.py +110 -0
  35. polyadmin/fastapi/pages.py +97 -0
  36. polyadmin/fastapi/relations.py +264 -0
  37. polyadmin/fastapi/responses.py +55 -0
  38. polyadmin/fastapi/router.py +174 -0
  39. polyadmin/fastapi/static.py +21 -0
  40. polyadmin/i18n/__init__.py +50 -0
  41. polyadmin/i18n/context.py +54 -0
  42. polyadmin/i18n/negotiation.py +56 -0
  43. polyadmin/i18n/setup.py +81 -0
  44. polyadmin/i18n/translator.py +124 -0
  45. polyadmin/locale/fr/LC_MESSAGES/polyadmin.mo +0 -0
  46. polyadmin/locale/fr/LC_MESSAGES/polyadmin.po +486 -0
  47. polyadmin/locale/polyadmin.pot +485 -0
  48. polyadmin/locale/ru/LC_MESSAGES/polyadmin.mo +0 -0
  49. polyadmin/locale/ru/LC_MESSAGES/polyadmin.po +496 -0
  50. polyadmin/templates/admin/base.html +91 -0
  51. polyadmin/templates/admin/components/action_confirm_modal.html +86 -0
  52. polyadmin/templates/admin/components/csrf-field.html +5 -0
  53. polyadmin/templates/admin/components/error_fragment.html +6 -0
  54. polyadmin/templates/admin/components/field.html +60 -0
  55. polyadmin/templates/admin/components/form_wrapper.html +131 -0
  56. polyadmin/templates/admin/components/icons.html +67 -0
  57. polyadmin/templates/admin/components/inline.html +251 -0
  58. polyadmin/templates/admin/components/inline_fragment.html +2 -0
  59. polyadmin/templates/admin/components/list_content.html +54 -0
  60. polyadmin/templates/admin/components/lookup_results.html +19 -0
  61. polyadmin/templates/admin/components/search.html +19 -0
  62. polyadmin/templates/admin/components/toasts.html +151 -0
  63. polyadmin/templates/admin/components/ui/breadcrumb.html +30 -0
  64. polyadmin/templates/admin/components/ui/bulk-actions.html +69 -0
  65. polyadmin/templates/admin/components/ui/calendar.html +175 -0
  66. polyadmin/templates/admin/components/ui/combobox.html +82 -0
  67. polyadmin/templates/admin/components/ui/delete-preview.html +37 -0
  68. polyadmin/templates/admin/components/ui/dropdown-menu.html +71 -0
  69. polyadmin/templates/admin/components/ui/field.html +110 -0
  70. polyadmin/templates/admin/components/ui/filter-panel.html +155 -0
  71. polyadmin/templates/admin/components/ui/locale-switcher.html +30 -0
  72. polyadmin/templates/admin/components/ui/multi-select.html +253 -0
  73. polyadmin/templates/admin/components/ui/pagination.html +81 -0
  74. polyadmin/templates/admin/components/ui/radio-group.html +28 -0
  75. polyadmin/templates/admin/components/ui/select.html +165 -0
  76. polyadmin/templates/admin/components/ui/sidebar.html +175 -0
  77. polyadmin/templates/admin/components/ui/slider.html +22 -0
  78. polyadmin/templates/admin/components/ui/switch.html +36 -0
  79. polyadmin/templates/admin/components/ui/table.html +221 -0
  80. polyadmin/templates/admin/components/ui/theme-toggle.html +33 -0
  81. polyadmin/templates/admin/dashboard.html +35 -0
  82. polyadmin/templates/admin/error.html +33 -0
  83. polyadmin/templates/admin/login.html +94 -0
  84. polyadmin/templates/admin/resource/delete.html +29 -0
  85. polyadmin/templates/admin/resource/delete_selected.html +49 -0
  86. polyadmin/templates/admin/resource/detail.html +78 -0
  87. polyadmin/templates/admin/resource/form.html +5 -0
  88. polyadmin/templates/admin/resource/list.html +5 -0
  89. polyadmin/templates/admin/theme.html +372 -0
  90. polyadmin/templates/admin/widgets/activity.html +8 -0
  91. polyadmin/templates/admin/widgets/chart.html +15 -0
  92. polyadmin/templates/admin/widgets/donut.html +59 -0
  93. polyadmin/templates/admin/widgets/metric.html +1 -0
  94. polyadmin/templates/admin/widgets/progress.html +7 -0
  95. polyadmin/templates/admin/widgets/stat.html +22 -0
  96. polyadmin/templates/admin/widgets/table.html +29 -0
  97. polyadmin/templates/admin/widgets/tabs.html +34 -0
  98. polyadmin/templates/admin/widgets/timeline.html +21 -0
  99. polyadmin/templating.py +528 -0
  100. polyadmin/ui.py +817 -0
  101. polyadmin-0.1.0b1.dist-info/METADATA +239 -0
  102. polyadmin-0.1.0b1.dist-info/RECORD +104 -0
  103. polyadmin-0.1.0b1.dist-info/WHEEL +4 -0
  104. polyadmin-0.1.0b1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,110 @@
1
+ {% from "admin/components/ui/combobox.html" import combobox %}
2
+ {% from "admin/components/ui/switch.html" import switch_control %}
3
+ {# Form/FormItem, ported from shadcn/ui: every branch below renders the
4
+ same label + control + description + error unit, with the wrapper
5
+ classes from ui('field', ...) so a custom field lines up with the
6
+ generated ones for free.
7
+
8
+ Controls that must survive a JS-free POST stay native elements styled
9
+ to match shadcn -- checkbox and <input type="date"> -- rather than
10
+ shadcn's Radix-backed versions. The Alpine-driven ports are the
11
+ relation combobox and the calendar popover.
12
+
13
+ `with context` so the combobox branch can read `base_path` for its
14
+ /lookup URL without every call site passing it through. #}
15
+ {% from "admin/components/icons.html" import icon %}
16
+ {% from "admin/components/ui/calendar.html" import date_picker %}
17
+ {% from "admin/components/ui/select.html" import select %}
18
+ {% from "admin/components/ui/multi-select.html" import multi_select %}
19
+ {# `key` keeps a control's element ids unique when the same field renders
20
+ more than once on a page, as it does in every row of an inline. #}
21
+ {% macro render_form_input(field, value, errors, relation=none, readonly_field=false, key="") %}
22
+ {% set input_types = {
23
+ "integer": "number",
24
+ "decimal": "number",
25
+ "email": "email",
26
+ "url": "url",
27
+ "datetime": "datetime-local",
28
+ "password": "password",
29
+ } %}
30
+ {# A boolean renders as a switch on the same row as its name: a toggle
31
+ reads as a setting, and a setting's name belongs beside it. Every
32
+ other type keeps the label-above-control layout. #}
33
+ {% set inline = field.field_type == "boolean" and not readonly_field %}
34
+ <div class="{{ ui('field') }}{% if inline %} {{ ui('field', 'row') }}{% endif %}">
35
+ {# Inline: the control comes first in source order, so the switch sits
36
+ to the left of the name it belongs to. #}
37
+ {% if inline %}
38
+ <div class="shrink-0">{{ switch_control(field.name, "field-" ~ field.name, value, field.disabled) }}</div>
39
+ {% endif %}
40
+ <span class="{% if inline %}{{ ui('field', 'row-text') }}{% else %}contents{% endif %}">
41
+ <label for="field-{{ field.name }}" class="{{ ui('field', 'label') }}{% if inline %} {{ ui('field', 'row-label') }}{% endif %}">
42
+ {{ _(field.label) }}{% if field.required %}<span class="{{ ui('field', 'required') }}" aria-hidden="true">*</span>{% endif %}
43
+ </label>
44
+ {% if inline %}
45
+ {% if field.help_text %}<p class="{{ ui('field', 'description') }}">{{ _(field.help_text) }}</p>{% endif %}
46
+ {% for error in errors or [] %}<p class="{{ ui('field', 'message') }}">{{ error }}</p>{% endfor %}
47
+ {% endif %}
48
+ </span>
49
+ {% if not inline %}
50
+ <div class="{{ ui('field', 'control') }}">
51
+ {# Read-only wins over every type branch below: no control, and
52
+ nothing posted. The handler refuses the name too -- omitting the
53
+ input here is presentation, not protection. #}
54
+ {% if readonly_field %}
55
+ {% set shown = value|form_value(field.field_type) %}
56
+ <p class="{{ ui('field', 'readonly') }}">{{ shown if shown else "&mdash;" | safe }}</p>
57
+ {% elif field.field_type == "text" %}
58
+ <textarea id="field-{{ field.name }}" name="{{ field.name }}" placeholder="{{ _(field.placeholder) }}" autocomplete="off"
59
+ {% if field.required %}required{% endif %}
60
+ {% if field.readonly %}readonly{% endif %}
61
+ {% if field.disabled %}disabled{% endif %}
62
+ class="{{ ui('textarea') }}">{{ value or "" }}</textarea>
63
+
64
+ {% elif field.field_type == "date" %}
65
+ {# DatePicker: a native date input (which already posts YYYY-MM-DD
66
+ with no JS and gets the platform picker on mobile) plus the shadcn
67
+ Calendar port in a popover beside it. See components/ui/calendar.html. #}
68
+ {{ date_picker(field.name, value|form_value(field.field_type), field.required, field.disabled) }}
69
+ {% elif field.field_type == "enum" %}
70
+ {% set enum_options = namespace(pairs=[]) %}
71
+ {% for choice in field.choices %}{% set enum_options.pairs = enum_options.pairs + [(choice, choice)] %}{% endfor %}
72
+ {{ select(field.name, enum_options.pairs, selected=value, placeholder=_("Select…"), disabled=field.disabled) }}
73
+ {% elif field.field_type in ("foreignkey", "onetoone") and relation and relation.autocomplete %}
74
+ {{ combobox(name=field.name,
75
+ id="combobox-results-" ~ (key ~ "-" if key else "") ~ field.name,
76
+ selected_pk=relation.selected_pk,
77
+ selected_label=relation.selected_label,
78
+ lookup_url=base_path ~ "/" ~ relation.lookup_target ~ "/lookup",
79
+ placeholder=_('Search…'),
80
+ disabled=field.disabled) }}
81
+
82
+ {% elif field.field_type in ("foreignkey", "onetoone") %}
83
+ {{ select(field.name, relation.options if relation else [],
84
+ selected=(relation.selected_pk if relation else none), placeholder="—", disabled=field.disabled) }}
85
+ {% elif field.field_type == "manytomany" %}
86
+ {% set m2m_options = namespace(pairs=[]) %}
87
+ {% for pk, label in (relation.options if relation else []) %}
88
+ {% set m2m_options.pairs = m2m_options.pairs + [(pk, label, relation and pk in relation.selected_pks)] %}
89
+ {% endfor %}
90
+ {{ multi_select(field.name, m2m_options.pairs, placeholder=_("Select…"), disabled=field.disabled) }}
91
+ {% else %}
92
+ <input type="{{ input_types.get(field.field_type, 'text') }}" id="field-{{ field.name }}" name="{{ field.name }}"
93
+ value="{{ value|form_value(field.field_type) }}" placeholder="{{ _(field.placeholder) }}" autocomplete="off"
94
+ {% if field.required %}required{% endif %}
95
+ {% if field.readonly %}readonly{% endif %}
96
+ {% if field.disabled %}disabled{% endif %}
97
+ class="{{ ui('input') }}">
98
+ {% endif %}
99
+ </div>
100
+ {% endif %}
101
+ {% if not inline %}
102
+ {% if field.help_text %}
103
+ <p class="{{ ui('field', 'description') }}">{{ _(field.help_text) }}</p>
104
+ {% endif %}
105
+ {% for error in errors or [] %}
106
+ <p class="{{ ui('field', 'message') }}">{{ error }}</p>
107
+ {% endfor %}
108
+ {% endif %}
109
+ </div>
110
+ {% endmacro %}
@@ -0,0 +1,155 @@
1
+ {% from "admin/components/ui/combobox.html" import combobox %}
2
+ {% from "admin/components/icons.html" import icon %}
3
+ {# Filter drawer: one "Filters" trigger in the toolbar plus a sheet
4
+ that slides in from the right listing every declared filter. One
5
+ trigger stays one trigger however many filters a ModelAdmin
6
+ declares, and vertical space in a drawer is free in a way horizontal
7
+ space in a toolbar is not.
8
+
9
+ Each choice is a link, not a form control, so filtering works with JS
10
+ off and the URLs are the real navigation. The precomputed URLs keep
11
+ query-string assembly out of the template.
12
+
13
+ Reset lives in the footer rather than the toolbar: it clears search
14
+ *and* every filter, so it belongs with the things it clears.
15
+
16
+ The open flag is mirrored onto a window global so it survives htmx's
17
+ outerHTML swap of #resource-list -- applying a filter re-renders this
18
+ component, and the drawer would otherwise slam shut on every click.
19
+ It is read back at init, so a fresh page load still starts closed.
20
+
21
+ Included, not a macro, so it shares list_content.html's ambient
22
+ context (filter_controls, active_filter_count, has_active_filters,
23
+ reset_url). #}
24
+ {% if filter_controls %}
25
+ <div x-data="{ open: window.polyadminFiltersOpen === true }"
26
+ x-effect="window.polyadminFiltersOpen = open"
27
+ @keydown.escape.window="open = false"
28
+ class="{{ ui('toolbar', 'item') }}">
29
+ <button type="button" @click="open = !open"
30
+ :aria-expanded="open ? 'true' : 'false'" aria-haspopup="dialog"
31
+ class="{{ ui('button', 'outline', 'size-sm') }} {{ ui('toolbar', 'item') }}">
32
+ {{ icon("filter", "size-4 shrink-0 " ~ ui('toolbar', 'item-icon')) }}
33
+ <span class="{{ ui('toolbar', 'item-label') }}">{{ _("Filters") }}</span>
34
+ {% if active_filter_count %}
35
+ <span class="{{ ui('separator', 'vertical') }} mx-1 !h-4"></span>
36
+ <span class="{{ ui('badge', 'secondary') }} {{ ui('filter-panel', 'count') }}">{{ active_filter_count }}</span>
37
+ {% endif %}
38
+ </button>
39
+
40
+ <div x-show="open" x-cloak @click="open = false"
41
+ x-transition:enter="transition ease-out duration-200"
42
+ x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
43
+ x-transition:leave="transition ease-in duration-150"
44
+ x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
45
+ class="{{ ui('sheet', 'overlay') }}"></div>
46
+
47
+ {# x-trap is what makes aria-modal="true" true: without it the drawer
48
+ claims to be modal while Tab walks straight out into the page
49
+ behind the overlay. It also moves focus into the panel on open and
50
+ back to the trigger on close. #}
51
+ <div x-show="open" x-cloak x-trap="open" role="dialog" aria-modal="true" aria-label="{{ _('Filters') }}"
52
+ x-transition:enter="transition ease-in-out duration-300"
53
+ x-transition:enter-start="translate-x-full" x-transition:enter-end="translate-x-0"
54
+ x-transition:leave="transition ease-in-out duration-300"
55
+ x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full"
56
+ class="{{ ui('sheet', 'content') }} {{ ui('sheet', 'side-right') }} {{ ui('sheet', 'panel') }}
57
+ {{ ui('sheet', 'width-panel') }} flex flex-col">
58
+ <div class="{{ ui('filter-panel', 'header') }}">
59
+ <h2 class="{{ ui('filter-panel', 'title') }}">{{ _("Filters") }}</h2>
60
+ <button type="button" @click="open = false" aria-label="{{ _('Close filters') }}"
61
+ class="{{ ui('button', 'ghost-muted', 'size-icon-sm') }}">
62
+ {{ icon("close", "size-4") }}
63
+ </button>
64
+ </div>
65
+
66
+ <div class="{{ ui('filter-panel', 'body') }}">
67
+ {% for control in filter_controls %}
68
+ <div>
69
+ <h3 class="{{ ui('filter-panel', 'group-label') }}">{{ _(control.label) }}</h3>
70
+ <ul>
71
+ {% for choice in control.choices %}
72
+ <li>
73
+ <a href="{{ choice.url }}"
74
+ hx-get="{{ choice.url }}" hx-target="#resource-list" hx-select="#resource-list"
75
+ hx-swap="outerHTML" hx-push-url="true"
76
+ class="{{ ui('filter-panel', 'choice') }}
77
+ {% if choice.selected %}{{ ui('filter-panel', 'choice-active') }}{% else %}{{ ui('filter-panel', 'choice-inactive') }}{% endif %}">
78
+ <span class="flex size-4 shrink-0 items-center justify-center">
79
+ {% if choice.selected %}{{ icon("check", "size-4") }}{% endif %}</span>
80
+ <span class="truncate">{{ _(choice.label) }}</span>
81
+ </a>
82
+ </li>
83
+ {% endfor %}
84
+ </ul>
85
+ {% if control.uses_combobox %}
86
+ {# The same control the form uses, in the same GET form the range
87
+ uses: the combobox writes the chosen pk into its own hidden
88
+ input, and Apply submits the form, so the panel needs no
89
+ bespoke navigation. #}
90
+ {# hx-disinherit: the combobox inside carries its own complete htmx
91
+ wiring for /lookup, and without this it would inherit this
92
+ form's hx-select="#resource-list" -- htmx would then select that
93
+ id out of the lookup's option list, find nothing, and swap
94
+ nothing, leaving the panel's combobox permanently empty. #}
95
+ <form method="get" action="{{ control.form_action }}" class="{{ ui('filter-panel', 'form') }}"
96
+ hx-get="{{ control.form_action }}" hx-target="#resource-list" hx-select="#resource-list"
97
+ hx-swap="outerHTML" hx-push-url="true" hx-disinherit="*">
98
+ {% for field in control.hidden %}
99
+ <input type="hidden" name="{{ field.name }}" value="{{ field.value }}">
100
+ {% endfor %}
101
+ {{ combobox(name="filter[" ~ control.name ~ "]",
102
+ id="filter-combobox-" ~ control.name,
103
+ selected_pk=control.selected_pk,
104
+ selected_label=control.selected_label,
105
+ lookup_url=control.lookup_url,
106
+ placeholder=_('Search…')) }}
107
+ <button type="submit" class="{{ ui('button', 'outline', 'size-xs') }} w-full">{{ _("Apply") }}</button>
108
+ </form>
109
+ {% endif %}
110
+ {% if control.kind == "daterange" %}
111
+ {# A GET form, not a JS-built URL: two date inputs cannot become
112
+ one parameter without either a form post or scripting, and the
113
+ form is the one that still works with scripting off. The route
114
+ folds _range_from/_range_to into the filter's own value
115
+ (fold_range_params). #}
116
+ <form method="get" action="{{ control.form_action }}" class="{{ ui('filter-panel', 'form') }}"
117
+ hx-get="{{ control.form_action }}" hx-target="#resource-list" hx-select="#resource-list"
118
+ hx-swap="outerHTML" hx-push-url="true">
119
+ {% for field in control.hidden %}
120
+ <input type="hidden" name="{{ field.name }}" value="{{ field.value }}">
121
+ {% endfor %}
122
+ <input type="hidden" name="{{ range_for_field }}" value="{{ control.name }}">
123
+ <p class="{{ ui('filter-panel', 'group-label') }}">{{ _("Custom range") }}</p>
124
+ <div class="{{ ui('filter-panel', 'form-row') }}">
125
+ <label for="range-from-{{ control.name }}" class="{{ ui('filter-panel', 'form-label') }}">{{ _("From") }}</label>
126
+ <input type="date" id="range-from-{{ control.name }}" name="{{ range_from_field }}"
127
+ value="{{ control.range_from }}"
128
+ class="{{ ui('input') }} {{ ui('filter-panel', 'form-input') }}">
129
+ </div>
130
+ <div class="{{ ui('filter-panel', 'form-row') }}">
131
+ <label for="range-to-{{ control.name }}" class="{{ ui('filter-panel', 'form-label') }}">{{ _("To") }}</label>
132
+ <input type="date" id="range-to-{{ control.name }}" name="{{ range_to_field }}"
133
+ value="{{ control.range_to }}"
134
+ class="{{ ui('input') }} {{ ui('filter-panel', 'form-input') }}">
135
+ </div>
136
+ <button type="submit" class="{{ ui('button', 'outline', 'size-xs') }} w-full">{{ _("Apply") }}</button>
137
+ </form>
138
+ {% endif %}
139
+ </div>
140
+ {% endfor %}
141
+ </div>
142
+
143
+ {% if has_active_filters %}
144
+ <div class="{{ ui('filter-panel', 'footer') }}">
145
+ <a href="{{ reset_url }}"
146
+ hx-get="{{ reset_url }}" hx-target="#resource-list" hx-select="#resource-list"
147
+ hx-swap="outerHTML" hx-push-url="true"
148
+ class="{{ ui('button', 'ghost-muted', 'size-sm') }} w-full">
149
+ {{ icon("close", "size-4") }} {{ _("Clear all") }}
150
+ </a>
151
+ </div>
152
+ {% endif %}
153
+ </div>
154
+ </div>
155
+ {% endif %}
@@ -0,0 +1,30 @@
1
+ {# Language switcher. Trigger and panel follow ui/dropdown-menu, which
2
+ renders only links; each option here is a small form posting to the
3
+ switcher route, like the sidebar's sign-out.
4
+
5
+ Each language is named in itself and never translated, so someone who
6
+ lands on a page they cannot read can still find their own. The `lang`
7
+ attribute on each option says so to assistive tech. #}
8
+ {% from "admin/components/csrf-field.html" import csrf_field %}
9
+ {% from "admin/components/icons.html" import icon %}
10
+ {% macro locale_switcher_menu(switcher, base_path, csrf_token) %}
11
+ <div class="relative inline-flex" x-data="{ open: false }" @keydown.escape="open = false" @click.outside="open = false">
12
+ <button type="button" @click="open = !open" :aria-expanded="open ? 'true' : 'false'" aria-haspopup="menu"
13
+ class="{{ ui('button', 'ghost-muted', 'size-icon-sm') }}">
14
+ {{ icon("language", "w-4 h-4") }}<span class="sr-only">{{ _("Language") }}</span>
15
+ </button>
16
+ <div x-show="open" x-cloak x-transition role="menu" class="{{ ui('dropdown', 'content') }} absolute right-0 top-full mt-1">
17
+ {% for option in switcher.options %}
18
+ <form method="post" action="{{ base_path }}/locale">
19
+ {{ csrf_field(csrf_token) }}
20
+ <input type="hidden" name="locale" value="{{ option.code }}">
21
+ <button type="submit" role="menuitemradio" lang="{{ option.code }}"
22
+ aria-checked="{{ 'true' if option.code == switcher.current else 'false' }}"
23
+ class="{{ ui('dropdown', 'item') }}">
24
+ <span class="w-4">{% if option.code == switcher.current %}{{ icon("check", "w-4 h-4") }}{% endif %}</span>{{ option.name }}
25
+ </button>
26
+ </form>
27
+ {% endfor %}
28
+ </div>
29
+ </div>
30
+ {% endmacro %}
@@ -0,0 +1,253 @@
1
+ {% from "admin/components/icons.html" import icon %}
2
+ {# MultiSelect: a searchable Command-style list over a many-to-many
3
+ relation's options, with the current selection shown as removable
4
+ chips on the trigger.
5
+
6
+ Same job as Django admin's filter_horizontal permissions widget -- a
7
+ long option list is unusable until you can type at it -- built from
8
+ shadcn's Combobox/Command idiom rather than Django's two-pane layout,
9
+ which needs more width than a form column has.
10
+
11
+ Filtering is client-side, unlike the relation combobox: a
12
+ many-to-many's options are already in the page in full, so a
13
+ round-trip per keystroke would be pure latency.
14
+
15
+ It posts one hidden input per selection under the field's own name,
16
+ exactly what a <select multiple> posts, so the handler cannot tell
17
+ the difference. An empty selection posts nothing, again matching the
18
+ native control.
19
+
20
+ Values and labels ride in on data-value/data-label and are read back
21
+ via $el.dataset, never interpolated into an Alpine expression.
22
+
23
+ Args:
24
+ name form field name
25
+ options [(value, label, selected), ...]
26
+ placeholder shown when nothing is chosen
27
+ disabled bool #}
28
+ {% macro multi_select(name, options, placeholder=_("Select…"), disabled=false, compact=false) %}
29
+ {# Focus stays in the search box -- typing is the point of this control
30
+ -- and the arrow keys move a highlight the input reports through
31
+ aria-activedescendant. Moving real focus into the list would mean you
32
+ could not keep typing. #}
33
+ {# hydrate on the next tick: the options live in a teleported template
34
+ (see the popover below), which Alpine mounts after x-init runs, so
35
+ reading $refs.options now would find nothing and the control would
36
+ open with no selection. #}
37
+ <div class="{{ ui('combobox') }}" x-data="adminMultiSelect()" x-init="$nextTick(() => hydrate())"
38
+ x-id="['ms-option', 'ms-listbox']"
39
+ @keydown.escape="closeToTrigger()" @click.outside="close()">
40
+ {# Real inputs rather than one comma-joined value: the server already
41
+ speaks "repeated field name", and joining would mean inventing a
42
+ separator some primary key eventually contains. #}
43
+ <template x-for="item in selected" :key="item.value">
44
+ <input type="hidden" name="{{ name }}" :value="item.value">
45
+ </template>
46
+
47
+ <button type="button" x-ref="trigger" @click="toggleOpen()"
48
+ :aria-expanded="open ? 'true' : 'false'" aria-haspopup="listbox"
49
+ :aria-controls="$id('ms-listbox')"
50
+ @keydown.down.prevent="openList()" @keydown.enter.prevent="openList()"
51
+ {% if disabled %}disabled{% endif %}
52
+ class="{{ ui('select', 'cell-multi') if compact else ui('select') ~ ' ' ~ ui('select', 'size-auto') }} {{ ui('multi-select', 'trigger') }}">
53
+ <span class="{{ ui('multi-select', 'values') }}">
54
+ <template x-for="item in selected" :key="item.value">
55
+ <span class="{{ ui('badge', 'secondary') }} {{ ui('multi-select', 'chip') }}">
56
+ <span x-text="item.label"></span>
57
+ {# Formatted on the server, so a translation's own word order
58
+ places the {label} marker; Alpine only swaps it for the chip's
59
+ label, read off the element like every other. #}
60
+ <span role="button" tabindex="0" data-remove-label="{{ _('Remove %(label)s', label='{label}') }}"
61
+ :aria-label="$el.dataset.removeLabel.replace('{label}', () => item.label)"
62
+ @click.stop="remove(item.value)" @keydown.enter.stop.prevent="remove(item.value)"
63
+ @keydown.space.stop.prevent="remove(item.value)"
64
+ class="{{ ui('multi-select', 'chip-remove') }}">
65
+ {{ icon("close", "size-3") }}
66
+ </span>
67
+ </span>
68
+ </template>
69
+ <span x-show="!selected.length" class="{{ ui('text', 'placeholder') }}">{{ placeholder }}</span>
70
+ </span>
71
+ {{ icon("chevron-down", "size-4 shrink-0 text-muted-foreground") }}
72
+ </button>
73
+
74
+ {# Portalled to <body>, as Radix does -- see the relation combobox in
75
+ ui/field.html for why. #}
76
+ <template x-teleport="body">
77
+ <div x-show="open" x-cloak
78
+ x-anchor.bottom-start.offset.4="$refs.trigger"
79
+ {# A cell trigger is too narrow to size the popover by, so there it
80
+ only sets a floor and the options decide the width. On a form the
81
+ popover matches the field. #}
82
+ x-effect="if (open && $refs.trigger) {
83
+ {% if compact %}$el.style.width = 'auto'; $el.style.minWidth = $refs.trigger.offsetWidth + 'px';
84
+ {% else %}$el.style.width = $refs.trigger.offsetWidth + 'px';{% endif %}
85
+ }"
86
+ x-transition:enter="transition ease-out duration-150"
87
+ x-transition:enter-start="opacity-0 scale-95"
88
+ x-transition:enter-end="opacity-100 scale-100"
89
+ class="{{ ui('combobox', 'content') }}">
90
+ <div class="{{ ui('multi-select', 'search') }}">
91
+ {{ icon("search", ui('combobox', 'icon')) }}
92
+ {# role=combobox on the input, not the wrapper: the input is what
93
+ holds focus, so it is what has to advertise the popup and name
94
+ the highlighted option. #}
95
+ <input type="text" x-ref="search" x-model="query" autocomplete="off" placeholder="{{ _('Search…') }}"
96
+ role="combobox" aria-autocomplete="list" :aria-expanded="open ? 'true' : 'false'"
97
+ :aria-controls="$id('ms-listbox')" :aria-activedescendant="activeId || null"
98
+ @keydown.escape.stop="closeToTrigger()"
99
+ @keydown.down.prevent="move(1)" @keydown.up.prevent="move(-1)"
100
+ @keydown.home.prevent="setActive(availableEls()[0])"
101
+ @keydown.end.prevent="setActive(availableEls().slice(-1)[0])"
102
+ @keydown.enter.prevent="chooseActive()"
103
+ class="{{ ui('input', 'bare') }}">
104
+ </div>
105
+ {# The list holds what you can still add: picking an option moves it
106
+ into a chip on the trigger. So nothing here is ever in a selected
107
+ state -- hence no check indicator and no aria-selected. The chips
108
+ are the record of what is chosen. #}
109
+ <div x-ref="options" role="listbox" :id="$id('ms-listbox')" class="{{ ui('multi-select', 'list') }}">
110
+ {% for value, label, selected in options %}
111
+ <div role="option" tabindex="-1" :id="$id('ms-option', {{ loop.index0 }})"
112
+ data-value="{{ value }}" data-label="{{ label }}" {% if selected %}data-selected="true"{% endif %}
113
+ @click="pick($el)"
114
+ x-show="available($el)"
115
+ class="{{ ui('combobox', 'item') }}">
116
+ <span class="truncate">{{ label }}</span>
117
+ </div>
118
+ {% endfor %}
119
+ {# Two different emptinesses, and saying "No matches" for the
120
+ second one would be a lie. #}
121
+ <div x-show="!availableCount()" x-cloak class="{{ ui('combobox', 'empty') }}"
122
+ x-text='query.trim() ? {{ _("No matches.")|tojson }} : {{ _("Everything is selected.")|tojson }}'></div>
123
+ </div>
124
+ </div>
125
+ </template>
126
+ </div>
127
+ {% endmacro %}
128
+
129
+ {% macro multi_select_script() %}
130
+ <script>
131
+ // Idempotent global, same pattern as window.adminCalendar
132
+ // (ui/calendar.html) -- a form may have several many-to-many fields,
133
+ // but only the first copy defines the factory.
134
+ window.adminMultiSelect = window.adminMultiSelect || function () {
135
+ // Single space-free class, toggled with classList rather than an
136
+ // x-bind: the options are server-rendered, not in an x-for.
137
+ var ACTIVE = "{{ ui('combobox', 'item-active') }}";
138
+ return {
139
+ open: false,
140
+ query: "",
141
+ // The highlighted (not chosen) option, named by the search
142
+ // input's aria-activedescendant while focus stays in the input.
143
+ activeEl: null,
144
+ activeId: "",
145
+ // [{value, label}], seeded from whichever options the server
146
+ // marked data-selected. The DOM is the source of truth at load for
147
+ // the same reason the relation combobox reads its initial value
148
+ // off the rendered input: no server state has to survive a trip
149
+ // through a JS literal.
150
+ selected: [],
151
+
152
+ hydrate() {
153
+ this.selected = Array.from(
154
+ this.$refs.options.querySelectorAll("[data-selected]")
155
+ ).map(function (el) {
156
+ return { value: el.dataset.value, label: el.dataset.label };
157
+ });
158
+ },
159
+
160
+ has(value) {
161
+ return this.selected.some(function (item) { return item.value === value; });
162
+ },
163
+
164
+ toggle(el) {
165
+ if (this.has(el.dataset.value)) {
166
+ this.remove(el.dataset.value);
167
+ } else {
168
+ this.selected.push({ value: el.dataset.value, label: el.dataset.label });
169
+ }
170
+ },
171
+
172
+ remove(value) {
173
+ this.selected = this.selected.filter(function (item) { return item.value !== value; });
174
+ },
175
+
176
+ // Substring match on the label, the same rule the server-side
177
+ // /lookup route applies for the autocomplete combobox.
178
+ matches(el) {
179
+ var q = this.query.trim().toLowerCase();
180
+ return q === "" || (el.dataset.label || "").toLowerCase().indexOf(q) !== -1;
181
+ },
182
+
183
+ // Still addable: matches the search *and* isn't already chosen.
184
+ available(el) {
185
+ return this.matches(el) && !this.has(el.dataset.value);
186
+ },
187
+
188
+ // The options you could still pick, in document order -- what the
189
+ // arrow keys walk. Recomputed per call rather than cached: the
190
+ // search query and the selection both change what is available,
191
+ // and both change while the list is open.
192
+ availableEls() {
193
+ var self = this;
194
+ return Array.from(this.$refs.options.children).filter(function (el) {
195
+ return el.dataset.value !== undefined && self.available(el);
196
+ });
197
+ },
198
+
199
+ availableCount() { return this.availableEls().length; },
200
+
201
+ setActive(el) {
202
+ Array.from(this.$refs.options.children).forEach(function (o) {
203
+ o.classList.remove(ACTIVE);
204
+ });
205
+ this.activeEl = el || null;
206
+ this.activeId = el ? el.id : "";
207
+ if (el) {
208
+ el.classList.add(ACTIVE);
209
+ el.scrollIntoView({ block: "nearest" });
210
+ }
211
+ },
212
+
213
+ clearActive() { this.setActive(null); },
214
+
215
+ move(delta) {
216
+ if (!this.open) { this.open = true; }
217
+ var opts = this.availableEls();
218
+ if (!opts.length) { this.clearActive(); return; }
219
+ var i = opts.indexOf(this.activeEl);
220
+ i = i < 0 ? (delta > 0 ? 0 : opts.length - 1) : (i + delta + opts.length) % opts.length;
221
+ this.setActive(opts[i]);
222
+ },
223
+
224
+ // Picking removes the option from the available list, so the
225
+ // highlight has to move rather than sit on a now-hidden row.
226
+ pick(el) {
227
+ var opts = this.availableEls();
228
+ var next = opts[opts.indexOf(el) + 1] || opts[opts.indexOf(el) - 1] || null;
229
+ this.toggle(el);
230
+ this.setActive(next);
231
+ },
232
+
233
+ chooseActive() { if (this.activeEl) this.pick(this.activeEl); },
234
+
235
+ openList() {
236
+ var self = this;
237
+ this.open = true;
238
+ this.$nextTick(function () {
239
+ if (self.$refs.search) self.$refs.search.focus();
240
+ self.setActive(self.availableEls()[0]);
241
+ });
242
+ },
243
+
244
+ close() { this.open = false; this.clearActive(); },
245
+
246
+ closeToTrigger() { var wasOpen = this.open; this.close(); if (wasOpen) this.$refs.trigger.focus(); },
247
+
248
+ // Distinct from toggle(el), which adds or removes one option.
249
+ toggleOpen() { if (this.open) { this.close(); } else { this.openList(); } },
250
+ };
251
+ };
252
+ </script>
253
+ {% endmacro %}
@@ -0,0 +1,81 @@
1
+ {# DataTablePagination, from shadcn's Tasks example: selection count
2
+ left, then rows-per-page, the page indicator and the four jump
3
+ buttons right.
4
+
5
+ It always renders, even for a single page: the row count and the
6
+ rows-per-page control are useful with nothing to page through, and a
7
+ footer that vanishes makes the table appear to jump as you filter.
8
+
9
+ Every URL is precomputed, so this template assembles no query
10
+ strings, and each is a real href as well as an hx-get, so paging
11
+ works with JS off.
12
+
13
+ Included, not a macro, so it shares list_content.html's context. #}
14
+ {% from "admin/components/icons.html" import icon %}
15
+ {% set swap = 'hx-target="#resource-list" hx-select="#resource-list" hx-swap="outerHTML" hx-push-url="true"' %}
16
+ <nav class="{{ ui('pagination') }}">
17
+ {# The example shows "N of M row(s) selected". Selection is client
18
+ side, so the count is filled in by Alpine from the row checkboxes;
19
+ with no actions declared there are no checkboxes and it degrades to
20
+ a plain total.
21
+
22
+ The sentence is translated whole, its plural chosen by M, with {n}
23
+ left for Alpine to fill in. ngettext yields text rather than a JS
24
+ literal, so the sentence rides in on data-selected-text for x-text
25
+ to read, and the server renders the n = 0 sentence so the text is
26
+ right before Alpine runs. #}
27
+ <p class="{{ ui('pagination', 'selection') }}"
28
+ {% if actions %}
29
+ x-data="{ n: 0 }"
30
+ x-init="$el.closest('#resource-list').addEventListener('change', () => {
31
+ n = $el.closest('#resource-list').querySelectorAll('.row-checkbox:checked').length })"
32
+ data-selected-text="{{ ngettext('%(n)s of %(num)d row selected', '%(n)s of %(num)d rows selected', page.total_count, n='{n}') }}"
33
+ x-text="$el.dataset.selectedText.replace('{n}', () => n)"
34
+ {% endif %}>
35
+ {% if actions %}{{ ngettext("%(n)s of %(num)d row selected", "%(n)s of %(num)d rows selected", page.total_count, n=0) }}{% else %}{{ ngettext("%(num)d row", "%(num)d rows", page.total_count) }}{% endif %}
36
+ </p>
37
+
38
+ <div class="{{ ui('pagination', 'controls') }}">
39
+ <div class="{{ ui('pagination', 'rows-per-page') }}">
40
+ <span class="hidden sm:inline">{{ _("Rows per page") }}</span>
41
+ <div class="relative" x-data="{ open: false }" @keydown.escape="open = false" @click.outside="open = false">
42
+ <button type="button" x-ref="sizeTrigger" @click="open = !open"
43
+ :aria-expanded="open ? 'true' : 'false'" aria-haspopup="menu"
44
+ class="{{ ui('button', 'outline', 'size-xs') }} h-8 w-[70px] justify-between">
45
+ {{ page.page_size }}{{ icon("chevrons-up-down", "size-3.5 shrink-0 opacity-50") }}
46
+ </button>
47
+ <div x-show="open" x-cloak role="menu"
48
+ x-anchor.top-start.offset.6="$refs.sizeTrigger"
49
+ class="{{ ui('dropdown', 'content') }} min-w-[70px]">
50
+ {% for option in page_size_options %}
51
+ <a href="{{ option.url }}" role="menuitem" {{ swap | safe }} @click="open = false"
52
+ class="{{ ui('dropdown', 'item') }} {{ 'font-medium text-foreground' if option.selected else '' }}">
53
+ {{ option.size }}
54
+ </a>
55
+ {% endfor %}
56
+ </div>
57
+ </div>
58
+ </div>
59
+
60
+ <div class="{{ ui('pagination', 'page-indicator') }}">
61
+ {{ _("Page %(page)d of %(pages)d", page=page.number, pages=page.num_pages or 1) }}
62
+ </div>
63
+
64
+ <div class="{{ ui('pagination', 'jumps') }}">
65
+ {% for jump in [
66
+ {"url": page_urls.first, "enabled": page.has_previous, "icon": "chevron-double-left", "label": _("First page"), "edge": true},
67
+ {"url": page_urls.previous, "enabled": page.has_previous, "icon": "chevron-left", "label": _("Previous page"), "edge": false},
68
+ {"url": page_urls.next, "enabled": page.has_next, "icon": "chevron-right", "label": _("Next page"), "edge": false},
69
+ {"url": page_urls.last, "enabled": page.has_next, "icon": "chevron-double-right", "label": _("Last page"), "edge": true},
70
+ ] %}
71
+ <a title="{{ jump.label }}" aria-disabled="{{ 'false' if jump.enabled else 'true' }}"
72
+ {% if jump.enabled %}href="{{ jump.url }}" hx-get="{{ jump.url }}" {{ swap | safe }}{% else %}tabindex="-1"{% endif %}
73
+ class="{{ ui('button', 'outline', 'size-xs') }}
74
+ {{ ui('pagination', 'jump-edge') if jump.edge else ui('pagination', 'jump') }}
75
+ {{ '' if jump.enabled else ui('pagination', 'disabled') }}">
76
+ {{ icon(jump.icon, "size-4") }}<span class="sr-only">{{ jump.label }}</span>
77
+ </a>
78
+ {% endfor %}
79
+ </div>
80
+ </div>
81
+ </nav>