Compare commits

..
Author SHA1 Message Date
imperosol 0fe1a2529f style: standardize UE search style 2026-08-30 19:38:52 +02:00
17 changed files with 142 additions and 184 deletions
@@ -7,7 +7,7 @@
<form <form
hx-post="{{ url('club:club_new_members', club_id=club.id) }}" hx-post="{{ url('club:club_new_members', club_id=club.id) }}"
hx-disable="find input[type='submit']" hx-disabled-elt="find input[type='submit']"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-target="#member-fragment-container" hx-target="#member-fragment-container"
id="add_club_members_form" id="add_club_members_form"
+5 -10
View File
@@ -6,15 +6,10 @@
* for more efficient tree-shaking and gzip compression. * for more efficient tree-shaking and gzip compression.
*/ */
// Must be loaded before Apline
import htmx from "htmx.org";
import "htmx.org/dist/ext/hx-alpine-compat.js";
import "htmx.org/dist/ext/hx-prompt.js";
import "htmx.org/dist/ext/hx-download.js";
import sort from "@alpinejs/sort"; import sort from "@alpinejs/sort";
import Alpine from "alpinejs"; import Alpine from "alpinejs";
import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill"; import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
import htmx from "htmx.org";
import { limitedChoices } from "#core:alpine/limited-choices"; import { limitedChoices } from "#core:alpine/limited-choices";
import { expireOldStorage } from "#core:core/localstorage"; import { expireOldStorage } from "#core:core/localstorage";
import { default as navbar } from "#core:core/navbar"; import { default as navbar } from "#core:core/navbar";
@@ -49,16 +44,16 @@ polyfillCountryFlagEmojis();
* HTMX * HTMX
*/ */
document.body.addEventListener( document.body.addEventListener(
"htmx:before:request" as keyof HTMLElementEventMap, "htmx:beforeRequest" as keyof HTMLElementEventMap,
(event) => { (event) => {
(event as CustomEvent).detail.ctx.target.ariaBusy = true; (event as CustomEvent).detail.target.ariaBusy = true;
}, },
); );
document.body.addEventListener( document.body.addEventListener(
"htmx:before:swap" as keyof HTMLElementEventMap, "htmx:beforeSwap" as keyof HTMLElementEventMap,
(event) => { (event) => {
(event as CustomEvent).detail.ctx.target.ariaBusy = null; (event as CustomEvent).detail.target.ariaBusy = null;
}, },
); );
@@ -1,6 +1,6 @@
<form <form
hx-post="{{ url("core:user_visibility_fragment", user_id=form.instance.id) }}" hx-post="{{ url("core:user_visibility_fragment", user_id=form.instance.id) }}"
hx-disable="find input[type='submit']" hx-disabled-elt="find input[type='submit']"
hx-swap="outerHTML" x-data="{ isViewable: {{ form.is_viewable.value()|tojson }} }" hx-swap="outerHTML" x-data="{ isViewable: {{ form.is_viewable.value()|tojson }} }"
> >
{% for message in messages %} {% for message in messages %}
-16
View File
@@ -141,22 +141,6 @@ class TestSearchUsersView(TestSearchUsers):
response = self.client.get(reverse("core:search")) response = self.client.get(reverse("core:search"))
assert response.status_code == 200 assert response.status_code == 200
def test_search_with_whitelist_unique(self):
"""Test that when a user has a whitelist and appears in the results,
it appears only once.
This is a regression test (cf #1463)
"""
user = subscriber_user.make(is_viewable=False)
user.whitelisted_users.add(
*subscriber_user.make(_quantity=4, _bulk_create=True)
)
self.client.force_login(user)
response = self.client.get(
reverse("core:search", query={"query": user.last_name})
)
assert response.context_data["users"] == [user]
@pytest.mark.django_db @pytest.mark.django_db
def test_user_account_not_found(client: Client): def test_user_account_not_found(client: Client):
-1
View File
@@ -65,7 +65,6 @@ class SearchView(LoginRequiredMixin, TemplateView):
UserFilterSchema(search=query) UserFilterSchema(search=query)
.filter(User.objects.viewable_by(self.request.user)) .filter(User.objects.viewable_by(self.request.user))
.order_by(F("last_login").desc(nulls_last=True)) .order_by(F("last_login").desc(nulls_last=True))
.distinct()
) )
clubs = list(Club.objects.filter(name__icontains=query)[:5]) clubs = list(Club.objects.filter(name__icontains=query)[:5])
return super().get_context_data(**kwargs) | {"users": users, "clubs": clubs} return super().get_context_data(**kwargs) | {"users": users, "clubs": clubs}
@@ -28,23 +28,18 @@ export class ProductAjaxSelect extends AjaxSelect {
return []; return [];
} }
// In the context in which this method is called, `this` might be shadowed private getName(item: SimpleProductSchema, sanitize: typeof escape_html): string {
// We need to call it explicitly from the class itself
private static getName(
item: SimpleProductSchema,
sanitize: typeof escape_html,
): string {
return item.code ? `${sanitize(item.code)} - ${sanitize(item.name)}` : item.name; return item.code ? `${sanitize(item.code)} - ${sanitize(item.name)}` : item.name;
} }
protected renderOption(item: SimpleProductSchema, sanitize: typeof escape_html) { protected renderOption(item: SimpleProductSchema, sanitize: typeof escape_html) {
return `<div class="select-item"> return `<div class="select-item">
<span class="select-item-text">${ProductAjaxSelect.getName(item, sanitize)}</span> <span class="select-item-text">${this.getName(item, sanitize)}</span>
</div>`; </div>`;
} }
protected renderItem(item: SimpleProductSchema, sanitize: typeof escape_html) { protected renderItem(item: SimpleProductSchema, sanitize: typeof escape_html) {
return `<span>${ProductAjaxSelect.getName(item, sanitize)}</span>`; return `<span>${this.getName(item, sanitize)}</span>`;
} }
} }
@@ -1,10 +1,5 @@
<div id="student_card_form"> <div id="student_card_form">
<form <form hx-post="{{ action }}" hx-swap="outerHTML" hx-target="#student_card_form">
hx-post="{{ action }}"
hx-swap="outerHTML"
hx-target="#student_card_form"
hx-disable="input[type='submit']"
>
{% csrf_token %} {% csrf_token %}
<p>{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}</p> <p>{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}</p>
<input type="submit" value="{% trans %}Confirm{% endtrans %}" /> <input type="submit" value="{% trans %}Confirm{% endtrans %}" />
+1 -1
View File
@@ -217,7 +217,7 @@ C'est une technologie simple et puissante qui se veut comme le jQuery du web mod
### Htmx ### Htmx
[Site officiel](https://four.htmx.org/) [Site officiel](https://htmx.org/)
En plus de AlpineJS, linteractivité sur le site est augmentée via Htmx. En plus de AlpineJS, linteractivité sur le site est augmentée via Htmx.
C'est une librairie js qui s'utilise également au moyen d'attributs HTML à C'est une librairie js qui s'utilise également au moyen d'attributs HTML à
@@ -41,7 +41,7 @@
hx-post="{{ url("election:apply_result", election_id=form.election.id) }}" hx-post="{{ url("election:apply_result", election_id=form.election.id) }}"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-target="#apply-election-result-fragment" hx-target="#apply-election-result-fragment"
hx-disable="find input[type='submit']" hx-disabled-elt="find input[type='submit']"
> >
{% csrf_token %} {% csrf_token %}
{{ form }} {{ form }}
+5 -11
View File
@@ -30,7 +30,7 @@
"easymde": "^2.21.0", "easymde": "^2.21.0",
"glob": "^13.0.6", "glob": "^13.0.6",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"htmx.org": "^4.0.0", "htmx.org": "^2.0.10",
"js-cookie": "^3.0.8", "js-cookie": "^3.0.8",
"lit-html": "^3.3.3", "lit-html": "^3.3.3",
"native-file-system-adapter": "^3.0.1", "native-file-system-adapter": "^3.0.1",
@@ -3801,16 +3801,10 @@
} }
}, },
"node_modules/htmx.org": { "node_modules/htmx.org": {
"version": "4.0.0", "version": "2.0.10",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-4.0.0.tgz", "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.10.tgz",
"integrity": "sha512-T/171FUY93Kdfp8t+DnHdk45QvKRiBhVhhrwSzrXgUi4pHKvhp77dUA/qg8FAjsFWPIHNbmUuIdCrcVHuiZWng==", "integrity": "sha512-kdeJe7ZVwaS6QMz/ebBIVtZdpwen6L0OQ5GOhPV9MKBb196TCZeZu4yA7ZIQsaLKv7EpXz+So7KSXNuHXhj7Cw==",
"license": "BSD-0-Clause", "license": "0BSD"
"workspaces": [
"ext/*"
],
"bin": {
"upgrade-check": "dist/scripts/upgrade-check.js"
}
}, },
"node_modules/ical.js": { "node_modules/ical.js": {
"version": "1.5.0", "version": "1.5.0",
+2 -2
View File
@@ -34,8 +34,8 @@
"@types/cytoscape-klay": "^3.1.5", "@types/cytoscape-klay": "^3.1.5",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/node": "^26.2.0", "@types/node": "^26.2.0",
"@typescript/native": "npm:typescript@^7.0.2",
"rollup-plugin-visualizer": "^7.1.1", "rollup-plugin-visualizer": "^7.1.1",
"@typescript/native": "npm:typescript@^7.0.2",
"typescript": "npm:@typescript/typescript6@^6.0.2", "typescript": "npm:@typescript/typescript6@^6.0.2",
"vite": "^8.2.2" "vite": "^8.2.2"
}, },
@@ -61,7 +61,7 @@
"easymde": "^2.21.0", "easymde": "^2.21.0",
"glob": "^13.0.6", "glob": "^13.0.6",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"htmx.org": "^4.0.0", "htmx.org": "^2.0.10",
"js-cookie": "^3.0.8", "js-cookie": "^3.0.8",
"lit-html": "^3.3.3", "lit-html": "^3.3.3",
"native-file-system-adapter": "^3.0.1", "native-file-system-adapter": "^3.0.1",
-8
View File
@@ -153,7 +153,6 @@ class UeFilterSchema(FilterSchema):
set[Literal["CS", "TM", "EC", "OM", "QC"]] | None, set[Literal["CS", "TM", "EC", "OM", "QC"]] | None,
FilterLookup("credit_type__in"), FilterLookup("credit_type__in"),
] = None ] = None
is_open: bool | None = None
language: str = "FR" language: str = "FR"
department: Annotated[set[str] | None, FilterLookup("department__in")] = None department: Annotated[set[str] | None, FilterLookup("department__in")] = None
@@ -188,10 +187,3 @@ class UeFilterSchema(FilterSchema):
return Q() return Q()
value.add("AUTUMN_AND_SPRING") value.add("AUTUMN_AND_SPRING")
return Q(semester__in=value) return Q(semester__in=value)
def filter_is_open(self, value: bool | None) -> Q: # noqa: FBT001
if value is None:
return Q()
if not value:
return Q(semester="CLOSED")
return ~Q(semester="CLOSED")
@@ -0,0 +1,118 @@
import {
getCurrentUrlParams,
History,
updateQueryString,
} from "#core:utils/history.ts";
import { ueFetchUeList } from "#openapi";
const pageDefault = 1;
const pageSizeDefault = 100;
document.addEventListener("alpine:init", () => {
Alpine.data("ue_search", () => ({
ues: {
count: 0,
next: null,
previous: null,
results: [],
},
loading: false,
page: pageDefault,
// biome-ignore lint/style/useNamingConvention: api is in snake_case
page_size: pageSizeDefault,
search: "",
department: [],
// biome-ignore lint/style/useNamingConvention: api is in snake_case
credit_type: [],
semester: [],
// biome-ignore lint/style/useNamingConvention: api is in snake_case
to_change: [],
pushstate: History.Push,
update: undefined,
initializeArgs() {
const url = getCurrentUrlParams();
this.pushstate = History.Replace;
this.page = Number.parseInt(url.get("page"), 10) || pageDefault;
this.page_size = Number.parseInt(url.get("page_size"), 10) || pageSizeDefault;
this.search = url.get("search") || "";
this.department = url.getAll("department");
this.credit_type = url.getAll("credit_type");
/* The semester is easier to use on the backend as an enum (spring/autumn/both/none)
and easier to use on the frontend as an array ([spring, autumn]).
Thus there is some conversion involved when both communicate together */
this.semester = url.has("semester") ? url.get("semester").split("_AND_") : [];
this.update();
},
async init() {
this.update = Alpine.debounce(async () => {
/* Create the whole url before changing everything all at once */
const first = this.to_change.shift();
let url = updateQueryString(first.param, first.value, History.None);
for (const value of this.to_change) {
url = updateQueryString(value.param, value.value, History.None, url);
}
updateQueryString(first.param, first.value, this.pushstate, url);
await this.fetchData(); /* reload data on form change */
this.to_change = [];
this.pushstate = History.Push;
}, 50);
const searchParams = ["search", "department", "credit_type", "semester"];
const paginationParams = ["page", "page_size"];
for (const param of searchParams) {
this.$watch(param, () => {
if (this.pushstate !== History.Push) {
/* This means that we are doing a mass param edit */
return;
}
/* Reset pagination on search */
this.page = pageDefault;
this.page_size = pageSizeDefault;
});
}
for (const param of searchParams.concat(paginationParams)) {
this.$watch(param, (value) => {
this.to_change.push({ param: param, value: value });
this.update();
});
}
window.addEventListener("popstate", () => {
this.initializeArgs();
});
this.initializeArgs();
},
async fetchData() {
this.loading = true;
const args = {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
page_size: this.page_size,
};
for (const [param, value] of new URL(
window.location.href,
).searchParams.entries()) {
// Deal with array type params
if (["credit_type", "department", "semester"].includes(param)) {
if (args[param] === undefined) {
args[param] = [];
}
args[param].push(value);
} else {
args[param] = value;
}
}
this.ues = (await ueFetchUeList({ query: args })).data;
this.loading = false;
},
maxPage() {
return Math.ceil(this.ues.count / this.page_size);
},
}));
});
@@ -1,114 +0,0 @@
import { getCurrentUrlParams, updateQueryString } from "#core:utils/history";
import { type SimpleUeSchema, ueFetchUeList } from "#openapi";
const pageDefault = 1;
const pageSizeDefault = 100;
document.addEventListener("alpine:init", () => {
Alpine.data("ue_search", () => ({
ues: {
count: 0,
next: null as string | null,
previous: null as string | null,
results: [] as SimpleUeSchema[],
},
loading: false,
page: pageDefault,
// biome-ignore lint/style/useNamingConvention: api is in snake_case
page_size: pageSizeDefault,
search: "",
hideClosedUes: true,
department: [] as string[],
// biome-ignore lint/style/useNamingConvention: api is in snake_case
credit_type: [] as string[],
semester: [] as string[],
// biome-ignore lint/style/useNamingConvention: api is in snake_case
to_change: [] as { param: string; value: string }[],
// dummy implementation to make TS happy.
// The real function is initialized in init
update: () => {
console.warn("Update not yet initialized");
},
initializeArgs() {
const url = getCurrentUrlParams();
this.page = Number.parseInt(url.get("page") || pageDefault.toString(), 10);
this.page_size = Number.parseInt(
url.get("page_size") || pageSizeDefault.toString(),
10,
);
this.search = url.get("search") || "";
this.hideClosedUes = url.get("hideClosed") || true;
this.department = url.getAll("department");
this.credit_type = url.getAll("credit_type");
/* The semester is easier to use on the backend as an enum (spring/autumn/both/none)
and easier to use on the frontend as an array ([spring, autumn]).
Thus there is some conversion involved when both communicate together */
this.semester = url.get("semester")?.split("_AND_") || [];
this.update();
},
async init() {
this.update = Alpine.debounce(async () => {
/* Create the whole url before changing everything all at once */
for (const val of this.to_change) {
updateQueryString(val.param, val.value);
}
await this.fetchData(); /* reload data on form change */
this.to_change = [];
}, 50);
const searchParams = [
"search",
"hideClosedUes",
"department",
"credit_type",
"semester",
];
const paginationParams = ["page", "page_size"];
for (const param of searchParams) {
this.$watch(param, () => {
/* Reset pagination on search */
this.page = pageDefault;
this.page_size = pageSizeDefault;
});
}
for (const param of searchParams.concat(paginationParams)) {
this.$watch(param, (value: string) => {
this.to_change.push({ param: param, value: value });
this.update();
});
}
this.initializeArgs();
},
async fetchData() {
this.loading = true;
const res = await ueFetchUeList({
query: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
page_size: this.page_size,
// biome-ignore lint/style/useNamingConvention: api is in snake_case
credit_type: this.credit_type.length > 0 ? this.credit_type : undefined,
semester: this.semester.length > 0 ? this.semester : undefined,
// biome-ignore lint/style/useNamingConvention: api is snake_case
is_open: this.hideClosedUes ? true : undefined,
department: this.department.length > 0 ? this.department : undefined,
search: this.search || undefined,
},
});
if (res.data !== undefined) {
this.ues = res.data;
}
this.loading = false;
},
maxPage() {
return Math.ceil(this.ues.count / this.page_size);
},
}));
});
+2 -2
View File
@@ -45,8 +45,8 @@
/> />
</fieldset> </fieldset>
<fieldset> <fieldset>
<input type="checkbox" class="switch" x-model="hideClosedUes" id="hide-closed-ues" name="hide-closed-ues"> <input type="checkbox" class="switch" name="hide_closed_ues" id="hide-closed-ues">
<label for="hide-closed-ues">{% trans %}Hide closed UEs{% endtrans %}</label> <label for="hide-closed-ues">{% trans %}Show closed UEs{% endtrans %}</label>
</fieldset> </fieldset>
<div class="row gap-3x margin-bottom radio-guide"> <div class="row gap-3x margin-bottom radio-guide">
<fieldset> <fieldset>
@@ -1,7 +1,7 @@
<form <form
hx-post="{{ url("subscription:fragment-existing-user") }}" hx-post="{{ url("subscription:fragment-existing-user") }}"
hx-target="this" hx-target="this"
hx-disable="find input[type='submit']" hx-disabled-elt="find input[type='submit']"
hx-swap="outerHTML" hx-swap="outerHTML"
> >
{% csrf_token %} {% csrf_token %}
@@ -1,7 +1,7 @@
<form <form
hx-post="{{ url("subscription:fragment-new-user") }}" hx-post="{{ url("subscription:fragment-new-user") }}"
hx-target="this" hx-target="this"
hx-disable="find input[type='submit']" hx-disabled-elt="find input[type='submit']"
hx-swap="outerHTML" hx-swap="outerHTML"
> >
{% csrf_token %} {% csrf_token %}