admin/members: add a dynamic regex/logic filter for the member list

This commit introduces a client-side table filter that supports regexes
and propositional logic to filter table rows.

A table can be filtered if it has the [filter-target] class and its
first row has <th> tags with a [data-filter] attribute specifying column
names.

The filter itself is a div with the [form] and [filter] classes, and a
[data-target] attribute pointing to the table to filter. The filter
contains a text <input> which is passed to filter_update() when the
filter expression is validated.

The client-side filter code runs the expression through a basic lexer
and parser, then matches the result for every row in the target table.
The [textContent] of each cell is used for string and regex matching.
This commit is contained in:
Lephe 2020-11-02 14:31:28 +01:00
parent 13b2bd2671
commit 1d38f906ee
Signed by: Lephenixnoir
GPG Key ID: 1BBA026E13FC0495
5 changed files with 212 additions and 6 deletions

View File

@ -122,3 +122,37 @@
font-family: monospace;
height: 192px;
}
/* Interactive filter forms */
.form.filter > p:first-child {
font-size: 80%;
color: gray;
margin-bottom: 2px;
}
.form.filter {
margin-bottom: 16px;
}
.form.filter input {
font-family: monospace;
}
.form.filter .syntax-explanation {
font-size: 80%;
color: gray;
margin-top: 8px;
}
.form.filter .syntax-explanation ul {
font-size: inherit;
color: inherit;
padding-left: 16px;
line-height: 20px;
margin-top: 2px;
}
.form.filter .syntax-explanation li {
}
.form.filter .syntax-explanation code {
background: rgba(0,0,0,.05);
padding: 1px 2px;
border-radius: 2px;
}

View File

@ -109,3 +109,13 @@ table.thread div.info {
width: 136px;
}
}
/* Tables with filters */
table.filter-target th:after {
content: attr(data-filter);
display: block;
font-size: 80%;
font-family: monospace;
font-weight: normal;
}

View File

@ -0,0 +1,143 @@
/* Tokens and patterns; keep arrays in order of token numbers */
const T = {
END:-2, ERR:-1, NAME:0, COMP:1, UNARY:2, AND:3, OR:4, LPAR:5, RPAR:6, STR:7
}
const patterns = [
/[a-z]+/, /=|!=|~=|!~=/, /!/, /&&/, /\|\|/, /\(/, /\)/, /"([^"]*)"/
]
function* lex(str) {
while(str = str.trim()) {
var t = T.ERR, best = undefined;
for(const i in patterns) {
const m = str.match(patterns[i]);
if(m === null || m.index != 0 || (best && m[0].length < best[0].length)) continue;
t = i;
best = m;
}
if(t == T.ERR) throw "LEXING ERROR";
yield [t, best[best.length-1]];
str = str.slice(best[0].length);
}
/* Finish with a continuous stream of T.END */
while(true) yield [T.END, undefined];
}
class Parser {
constructor(lex) {
this.lex = lex;
[this.la, this.value] = lex.next().value;
}
/* Expect a token of type t, returns the value */
expect(t) {
const v = this.value;
if(this.la != t) throw (`SYNTAX ERROR, EXPECTED ${t}, GOT ${this.la} ${this.value}`);
[this.la, this.value] = this.lex.next().value;
return v;
}
/* filter -> END | expr END */
filter() {
if(this.la == T.END) return true;
const e = this.expr();
this.expect(T.END);
return e;
}
/* expr -> term_and | term_and OR expr */
expr() {
const left = this.term_and();
if(this.la != T.OR) return left;
this.expect(T.OR);
return {
type: "Or",
left: left,
right: this.expr(),
};
}
/* term_and -> unary | unary AND term_and */
term_and() {
const left = this.unary();
if(this.la != T.AND) return left;
this.expect(T.AND);
return {
type: "And",
left: left,
right: this.term_and(),
};
}
/* unary -> UNARY* atom */
unary() {
if(this.la == T.UNARY) return {
type: "Unary",
op: this.expect(T.UNARY),
val: this.unary(),
};
return this.atom();
}
/* atom -> NAME COMP STR | LPAR expr RPAR */
atom() {
if(this.la == T.LPAR) {
this.expect(T.LPAR);
const e = this.expr();
this.expect(T.RPAR);
return e;
}
var e = {
type: "Atom",
field: this.expect(T.NAME),
op: this.expect(T.COMP),
};
const str = this.expect(T.STR);
/* Precompile regular expressions */
if(e.op == "~=" || e.op == "!~=")
e.regex = new RegExp(str, "i");
else
e.str = str;
return e;
}
}
function ev(e, row, fields) {
switch(e.type) {
case "Atom":
const val = row.children[fields[e.field]].textContent.trim();
if(e.op == "=") return val == e.str;
if(e.op == "!=") return val != e.str;
if(e.op == "~=") return e.regex.test(val);
if(e.op == "!~=") return !e.regex.test(val);
case "Unary":
if(e.op == "!") return !ev(e.val, row, fields);
case "And":
return ev(e.left, row, fields) && ev(e.right, row, fields);
case "Or":
return ev(e.left, row, fields) || ev(e.right, row, fields);
}
}
function filter_update(input) {
const t = document.querySelector(input.parentNode.dataset.target);
const th = t.querySelectorAll("tr:first-child > th");
/* Generate the names of fields from the header */
var fields = {};
for(var i = 0; i < th.length; i++) {
const name = th[i].dataset.filter;
if(name) fields[name] = i;
}
/* Parse the filter as an expression */
const parser = new Parser(lex(input.value));
const expr = parser.filter();
/* Evaluate the expression on each row of the table */
const rows = t.querySelectorAll("tr:not(:first-child)");
for(const row of rows) {
const ok = (expr === true) || ev(expr, row, fields);
row.style.display = ok ? "table-row" : "none";
}
}

View File

@ -6,13 +6,31 @@
{% block content %}
<section>
<h2>Listes des membres inscrits</h2>
<h2>Listes des membres</h2>
{# TODO: Barre de recherche interactive #}
<div class="form filter" data-target="#members">
<p>Filtrer les entrées :</p>
<input type="text" onchange="filter_update(this)">
<div class="syntax-explanation">Syntaxe :
<ul><li>Comparaisons avec <code>=</code> ou <code>!=</code> : <code>name="DarkStorm"</code></li>
<li>Comparaison regex avec <code>~=</code> ou <code>!~=</code> (insensible à la casse) : <code>name~="^dark"</code></li>
<li>Combiner avec <code>!</code>, <code>&&</code>, <code>||</code> et parenthèses : <code>(name~="^dark" || name="Lephenixnoir") && (groups~="administrateur")</code></li>
</ul>
</div>
<noscript>
<p><i>Le filtre nécessite l'activation de Javascript.</i></p>
</noscript>
</div>
<table style="width:90%; margin: auto;">
<tr><th>Pseudo</th><th>Email</th><th>Inscrit le</th><th>Groupes</th>
<th>Privilèges spéciaux</th><th>Modifier</th></tr>
<table id="members" class="filter-target" style="width:90%; margin: auto;">
<tr>
<th data-filter="name">Pseudo</th>
<th data-filter="email">Email</th>
<th data-filter="registration">Inscrit le</th>
<th data-filter="groups">Groupes</th>
<th data-filter="privs">Privilèges spéciaux</th>
<th>Modifier</th>
</tr>
{% for user in users %}
<tr><td><a href="{{ url_for('user_by_id', user_id=user.id) }}" title="Page de profil publique de {{ user.name }}">{{ user.name }}</a></td>

View File

@ -29,7 +29,8 @@ def render(*args, styles=[], scripts=[], **kwargs):
'scripts/trigger_menu.js',
'scripts/pc-utils.js',
'scripts/smartphone_patch.js',
'scripts/simplemde.min.js'
'scripts/simplemde.min.js',
'scripts/filter.js'
]
for s in styles: