Skip to content

Commit f0a4e52

Browse files
committed
Merge master into worktree-server-stopped-false-positive
2 parents c0b7ce9 + 2c59420 commit f0a4e52

45 files changed

Lines changed: 671 additions & 340 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

example/app/users/admin.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
from plain import postgres
12
from plain.admin.views import (
23
AdminModelDetailView,
34
AdminModelListView,
45
AdminViewset,
56
register_viewset,
67
)
8+
from plain.http import Response
79

810
from .models import User
911

@@ -17,8 +19,21 @@ class ListView(AdminModelListView):
1719
title = "Users"
1820
fields = ["id", "email", "is_admin", "created_at"]
1921
search_fields = ["email"]
22+
actions = ["Make admin", "Remove admin", "Export emails"]
2023
allow_global_search = True
2124
queryset_order = ["-created_at"]
2225

26+
def perform_action(
27+
self, action: str, objects: postgres.QuerySet
28+
) -> Response | None:
29+
if action == "Make admin":
30+
objects.update(is_admin=True)
31+
elif action == "Remove admin":
32+
objects.update(is_admin=False)
33+
elif action == "Export emails":
34+
emails = objects.values_list("email", flat=True)
35+
return Response("\n".join(emails), content_type="text/plain")
36+
return None
37+
2338
class DetailView(AdminModelDetailView):
2439
model = User

plain-admin/plain/admin/README.md

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -436,21 +436,19 @@ class ListView(AdminModelListView):
436436
fields = ["id", "email", "is_active"]
437437
actions = ["Activate", "Deactivate", "Delete selected"]
438438

439-
def perform_action(self, action, target_ids):
440-
users = User.query.filter(id__in=target_ids)
441-
439+
def perform_action(self, action, objects):
442440
if action == "Activate":
443-
users.update(is_active=True)
441+
objects.update(is_active=True)
444442
elif action == "Deactivate":
445-
users.update(is_active=False)
443+
objects.update(is_active=False)
446444
elif action == "Delete selected":
447-
users.delete()
445+
objects.delete()
448446

449447
# Return None to redirect back to the list, or return a Response
450448
return None
451449
```
452450

453-
The `target_ids` parameter contains the IDs of selected items. Users can select individual items or use "Select all" to target the entire filtered queryset.
451+
The `objects` parameter is the set of selected items, already narrowed to the current filtered view. On a model list it's a queryset, so you can run set-based `.update()`/`.delete()` directly without re-querying — and "Select all N" scales, because the whole page's worth of ids never has to be materialized. Users can select individual rows, use the header checkbox to select the whole current page, or click "Select all N" to target the entire filtered queryset across every page. Choosing an action from the **Actions** dropdown confirms before submitting.
454452

455453
## Toolbar
456454

plain-admin/plain/admin/assets/admin/list.js

Lines changed: 115 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,30 +3,92 @@ const SENTINEL_ALL = "__all__";
33
let actionCheckboxes = [];
44
let actionCheckboxHeader = null;
55
let actionIdsInput = null;
6+
let actionNameInput = null;
67
let actionForm = null;
8+
let selectionHeader = null;
9+
let selectionSummary = null;
10+
let selectAllButton = null;
11+
let actionMenuItems = [];
12+
13+
// All-pages selection is an explicit mode, entered only via the "Select all N"
14+
// button. Any deselection exits it. When active, action_ids is the __all__
15+
// sentinel the backend expands to every object across every page.
16+
let selectAllPages = false;
17+
let lastActionCheckboxChecked = null;
718

819
function refreshActionCache() {
920
actionCheckboxes = [...document.querySelectorAll("[data-action-checkbox]")];
1021
actionCheckboxHeader = document.querySelector("[data-action-checkbox-all]");
11-
actionIdsInput = document.querySelector('[name="action_ids"]');
1222
actionForm = document.querySelector("[data-actions-form]");
23+
actionIdsInput = actionForm?.querySelector('[name="action_ids"]') ?? null;
24+
actionNameInput = actionForm?.querySelector('[name="action_name"]') ?? null;
25+
selectionHeader = actionForm?.closest("header") ?? null;
26+
selectionSummary = document.querySelector("[data-selection-summary]");
27+
selectAllButton = document.querySelector("[data-action-select-all]");
28+
actionMenuItems = [...document.querySelectorAll("[data-action-name]")];
29+
updateSelectionUI();
1330
}
1431

1532
document.addEventListener("DOMContentLoaded", refreshActionCache);
16-
document.addEventListener("htmx:afterSwap", refreshActionCache);
33+
document.addEventListener("htmx:afterSwap", () => {
34+
selectAllPages = false;
35+
lastActionCheckboxChecked = null;
36+
refreshActionCache();
37+
});
1738

18-
let lastActionCheckboxChecked = null;
39+
function checkedCount() {
40+
return actionCheckboxes.filter((cb) => cb.checked).length;
41+
}
1942

20-
function updateActionIds() {
21-
if (!actionIdsInput) return;
22-
if (actionCheckboxHeader?.checked) {
23-
actionIdsInput.value = SENTINEL_ALL;
24-
} else {
25-
const ids = [];
26-
actionCheckboxes.forEach((cb) => {
27-
if (cb.checked) ids.push(cb.getAttribute("name"));
28-
});
29-
actionIdsInput.value = ids.join(",");
43+
function totalCount() {
44+
return parseInt(actionForm?.dataset.totalCount ?? "0", 10);
45+
}
46+
47+
function updateSelectionUI() {
48+
const totalOnPage = actionCheckboxes.length;
49+
const checked = checkedCount();
50+
const total = totalCount();
51+
const fullPageSelected = totalOnPage > 0 && checked === totalOnPage;
52+
53+
// Header checkbox reflects the current page: checked when the whole page is
54+
// selected, indeterminate when only some rows are.
55+
if (actionCheckboxHeader) {
56+
actionCheckboxHeader.checked = fullPageSelected;
57+
actionCheckboxHeader.indeterminate = checked > 0 && checked < totalOnPage;
58+
}
59+
60+
// Presence-based flag that swaps the results count for the selection bar.
61+
if (selectionHeader) {
62+
if (checked > 0) selectionHeader.setAttribute("data-selecting", "");
63+
else selectionHeader.removeAttribute("data-selecting");
64+
}
65+
66+
if (selectionSummary) {
67+
selectionSummary.textContent = selectAllPages ? `All ${total} selected` : `${checked} selected`;
68+
}
69+
70+
// The Actions dropdown is always visible, but its items only act when
71+
// something is selected — disable them otherwise (CSS dims + blocks them,
72+
// components.js drops them from keyboard nav, and the menu heading hints why).
73+
actionMenuItems.forEach((item) => {
74+
if (checked > 0) item.removeAttribute("aria-disabled");
75+
else item.setAttribute("aria-disabled", "true");
76+
});
77+
78+
// Offer "Select all N" only once the whole page is selected, we're not
79+
// already in all-pages mode, and there's more than this page to select.
80+
if (selectAllButton) {
81+
const showSelectAll = fullPageSelected && !selectAllPages && total > totalOnPage;
82+
selectAllButton.classList.toggle("hidden", !showSelectAll);
83+
}
84+
85+
if (actionIdsInput) {
86+
if (selectAllPages) {
87+
actionIdsInput.value = SENTINEL_ALL;
88+
} else {
89+
const ids = actionCheckboxes.filter((cb) => cb.checked).map((cb) => cb.getAttribute("name"));
90+
actionIdsInput.value = ids.join(",");
91+
}
3092
}
3193
}
3294

@@ -35,44 +97,54 @@ document.addEventListener("change", (e) => {
3597
if (!(target instanceof HTMLInputElement)) return;
3698

3799
if (target.matches("[data-action-checkbox]")) {
38-
if (actionCheckboxHeader?.checked) {
39-
const checkedCount = actionCheckboxes.filter((cb) => cb.checked).length;
40-
if (checkedCount !== actionCheckboxes.length) actionCheckboxHeader.checked = false;
41-
}
42-
updateActionIds();
100+
if (!target.checked) selectAllPages = false;
101+
updateSelectionUI();
43102
return;
44103
}
45104

46105
if (target.matches("[data-action-checkbox-all]")) {
47106
actionCheckboxes.forEach((cb) => {
48107
cb.checked = target.checked;
49108
});
50-
updateActionIds();
109+
if (!target.checked) selectAllPages = false;
110+
updateSelectionUI();
51111
}
52112
});
53113

54-
document.addEventListener("change", (e) => {
55-
const select = e.target;
56-
if (!select.matches('[name="action_name"]')) return;
57-
if (!select.value) return;
58-
59-
const actionName = select.value;
60-
const ids = actionIdsInput?.value ?? "";
61-
const count = ids && ids !== SENTINEL_ALL ? ids.split(",").length : 0;
62-
const target =
63-
ids === SENTINEL_ALL
64-
? "ALL items across all pages"
65-
: count > 0
66-
? `${count} selected ${count === 1 ? "item" : "items"}`
67-
: null;
68-
const confirmMessage = target
69-
? `Are you sure you want to perform "${actionName}" on ${target}?`
70-
: `Are you sure you want to perform "${actionName}"?`;
71-
72-
if (confirm(confirmMessage)) {
73-
actionForm?.submit();
74-
} else {
75-
select.value = "";
114+
document.addEventListener("click", (e) => {
115+
const target = e.target;
116+
if (!(target instanceof Element)) return;
117+
118+
if (target.closest("[data-action-select-all]")) {
119+
selectAllPages = true;
120+
updateSelectionUI();
121+
return;
122+
}
123+
124+
if (target.closest("[data-action-clear]")) {
125+
actionCheckboxes.forEach((cb) => {
126+
cb.checked = false;
127+
});
128+
selectAllPages = false;
129+
updateSelectionUI();
130+
return;
131+
}
132+
133+
const actionItem = target.closest("[data-action-name]");
134+
if (actionItem) {
135+
if (actionItem.getAttribute("aria-disabled") === "true") return;
136+
const actionName = actionItem.getAttribute("data-action-name");
137+
const checked = checkedCount();
138+
const confirmMessage = selectAllPages
139+
? `Are you sure you want to perform "${actionName}" on all ${totalCount()} items across all pages?`
140+
: `Are you sure you want to perform "${actionName}" on ${checked} selected item${checked === 1 ? "" : "s"}?`;
141+
142+
if (confirm(confirmMessage)) {
143+
if (actionNameInput) actionNameInput.value = actionName;
144+
actionForm?.submit();
145+
}
146+
// The dropdown-menu component closes itself on menuitem click, so a
147+
// cancelled confirm just leaves the menu closed with the selection intact.
76148
}
77149
});
78150

@@ -89,7 +161,8 @@ document.addEventListener("click", (e) => {
89161
const min = Math.min(thisIndex, lastIndex);
90162
const max = Math.max(thisIndex, lastIndex);
91163
for (let i = min; i <= max; i++) actionCheckboxes[i].checked = target.checked;
92-
updateActionIds();
164+
if (!target.checked) selectAllPages = false;
165+
updateSelectionUI();
93166
} else {
94167
lastActionCheckboxChecked = target;
95168
}

plain-admin/plain/admin/assets/admin/menu.js

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
/**
2-
* Admin menu — filter input inside the menu popover, plus drag-and-drop
3-
* reordering for pinned tabs in the header strip. The popover's
4-
* open/close behavior comes from basecoat (assets/admin/components/popover.js)
5-
* — this file only wires the filter and the tab DnD.
2+
* Admin menu — the filter inside the menu popover (filtering + empty state),
3+
* plus drag-and-drop reordering for pinned tabs in the header strip. The
4+
* popover's open/close behavior comes from basecoat
5+
* (assets/admin/components/popover.js).
66
*/
77
document.addEventListener("DOMContentLoaded", () => {
88
const filterInput = document.getElementById("menu-filter-input");
99
const menuPopover = document.getElementById("admin-menu-popover");
10+
const emptyState = document.getElementById("menu-empty-state");
1011

1112
if (filterInput) {
1213
filterInput.addEventListener("input", (e) => {
@@ -67,14 +68,22 @@ document.addEventListener("DOMContentLoaded", () => {
6768
}
6869
});
6970

70-
// Hide the App / Packages section entirely if nothing under it
71+
// Hide a whole section (label, divider, and all) if nothing under it
7172
// matches.
7273
sections.forEach((section) => {
7374
const hasVisibleItems = Array.from(section.querySelectorAll(".menu-item")).some(
7475
(item) => !item.classList.contains("hidden"),
7576
);
7677
section.classList.toggle("hidden", !hasVisibleItems);
7778
});
79+
80+
// Show the empty state when the filter matched nothing.
81+
if (emptyState) {
82+
const anyVisible = Array.from(container.querySelectorAll(".menu-item")).some(
83+
(item) => !item.classList.contains("hidden"),
84+
);
85+
emptyState.classList.toggle("hidden", anyVisible);
86+
}
7887
}
7988

8089
// Drag and drop for nav bar tabs (pinned items only)

plain-admin/plain/admin/styles/components/checkbox.css

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@
22
@layer components {
33
:is(.form, .admin-field) input[type='checkbox']:not([role='switch']),
44
.admin-input[type='checkbox']:not([role='switch']) {
5-
@apply focus-control appearance-none border-admin-input dark:bg-admin-input/30 checked:bg-admin-primary dark:checked:bg-admin-primary checked:border-admin-primary size-4 shrink-0 border shadow-xs transition-shadow outline-none disabled:cursor-not-allowed disabled:opacity-50;
5+
@apply focus-control appearance-none border-admin-input dark:bg-admin-input/30 checked:bg-admin-primary dark:checked:bg-admin-primary checked:border-admin-primary indeterminate:bg-admin-primary dark:indeterminate:bg-admin-primary indeterminate:border-admin-primary size-4 shrink-0 border shadow-xs transition-shadow outline-none disabled:cursor-not-allowed disabled:opacity-50;
66
border-radius: var(--radius-checkbox);
7-
7+
88
&:checked:after {
99
@apply content-[''] block size-3.5 bg-admin-primary-foreground;
1010
@apply mask-[image:var(--check-icon)] mask-size-[0.875rem] mask-no-repeat mask-center;
1111
}
12+
13+
&:indeterminate:after {
14+
@apply content-[''] block size-3.5 bg-admin-primary-foreground;
15+
@apply mask-[image:var(--minus-icon)] mask-size-[0.875rem] mask-no-repeat mask-center;
16+
}
1217
}
1318
}

plain-admin/plain/admin/styles/components/hovercard.css

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,14 @@
99
<span class="admin-hovercard">
1010
<some-trigger />
1111
<div data-hovercard>…panel content…</div>
12-
</span> */
12+
</span>
13+
14+
Placement: defaults to opening below the trigger. Set
15+
`data-hovercard-side="right|left|top|bottom"` on the panel — or on any
16+
ancestor, e.g. a table column — to change the side. Setting it on an
17+
ancestor lets a whole column of hovercards (including the auto-wrapped
18+
<time> ones) open to one side, keeping the trigger column clear so you
19+
can scan straight down it without the open panel covering the next row. */
1320
@layer components {
1421
.admin-hovercard {
1522
@apply relative inline-flex;
@@ -20,6 +27,7 @@
2027
border-radius: var(--radius-popover);
2128
--admin-overlay-fade: 100ms;
2229

30+
/* Default placement; overridden per-side below. */
2331
position-area: bottom span-right;
2432
position-try-fallbacks: flip-block, flip-inline;
2533
margin: 0.25rem 0;
@@ -41,4 +49,36 @@
4149
}
4250
}
4351
}
52+
53+
/* Placement overrides. Each side sets its own edge-flip order (flip toward
54+
the opposite side first) and swaps the gap to the relevant axis. Matched
55+
on the panel itself OR any ancestor carrying data-hovercard-side, so a
56+
container (a table column, say) can steer every hovercard inside it. */
57+
[data-hovercard][data-hovercard-side="right"],
58+
[data-hovercard-side="right"] [data-hovercard] {
59+
position-area: right span-bottom;
60+
position-try-fallbacks: flip-inline, flip-block;
61+
margin: 0 0.25rem;
62+
}
63+
64+
[data-hovercard][data-hovercard-side="left"],
65+
[data-hovercard-side="left"] [data-hovercard] {
66+
position-area: left span-bottom;
67+
position-try-fallbacks: flip-inline, flip-block;
68+
margin: 0 0.25rem;
69+
}
70+
71+
[data-hovercard][data-hovercard-side="top"],
72+
[data-hovercard-side="top"] [data-hovercard] {
73+
position-area: top span-right;
74+
position-try-fallbacks: flip-block, flip-inline;
75+
margin: 0.25rem 0;
76+
}
77+
78+
[data-hovercard][data-hovercard-side="bottom"],
79+
[data-hovercard-side="bottom"] [data-hovercard] {
80+
position-area: bottom span-right;
81+
position-try-fallbacks: flip-block, flip-inline;
82+
margin: 0.25rem 0;
83+
}
4484
}

plain-admin/plain/admin/styles/components/table.css

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@
1515
@apply hover:bg-admin-muted/50 border-b transition-colors;
1616
}
1717
th {
18-
@apply text-admin-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px];
18+
@apply text-admin-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap;
1919
}
2020
td {
21-
@apply p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px];
21+
@apply p-2 align-middle whitespace-nowrap;
2222
}
2323
caption {
2424
@apply text-admin-muted-foreground mt-4 text-sm;

0 commit comments

Comments
 (0)