simplify UI to make sub-menu tabs more obvious to users; unify language
This commit is contained in:
parent
55ad5e4cc0
commit
12341b68cd
10
README.md
10
README.md
|
|
@ -20,12 +20,12 @@ Open <http://127.0.0.1:8000>. The SQLite database is created at `data/preference
|
|||
|
||||
## How it works
|
||||
|
||||
- Existing login usernames are suggested as you type, before entering the shared password. The password is still required to sign in. A similar new username requires an explicit choice to create it. Login username matching ignores case.
|
||||
- Rank the WoW Forever classes and roles you want to express a preference for. Drag a row, use its arrow buttons, or focus the drag handle and press the arrow keys. Select × to move a category to **No preference**, or Restore to rank it again. You can exclude every category. For Content, choose Yes, Maybe, or No for each of the seven activities. Save each property separately.
|
||||
- Open **Group preferences** to see everyone's saved choices. Class and Role can display columns by rank or category. Excluded choices appear as blank cells and sort as the lowest preference; people who have not submitted that property appear last. Content always displays category columns. Select a column to sort, search by display name or login username, or refresh to load recent changes. When the names differ, the login username appears below the display name.
|
||||
- Open **Account** to change your login username or display name independently. Display names can be shared; login usernames must be unique. Your preferences and active sessions remain tied to your account when you rename it. You can also permanently delete your account, preferences, and sessions by typing your display name to confirm.
|
||||
- Similar existing login usernames are suggested as you type, before entering the shared password. The name already entered is never suggested. The password is still required to sign in. A similar new username requires an explicit choice to create it. Login username matching ignores case.
|
||||
- Rank the WoW Forever classes and roles you want to express a preference for. Drag a row, use its arrow buttons, or focus the drag handle and press the arrow keys. Select × to leave a category unranked, or select **Rank** to add it to your ranking. You can leave every category unranked. For Content, choose Yes, Maybe, or No for each of the seven activities. Save each property separately.
|
||||
- Open **Group preferences** to see everyone's saved choices. Class and Role can display columns by rank or category. Unranked choices appear as blank cells and sort after ranked choices; people who have not submitted that property appear last. Content always displays category columns. Select a column to sort, search by login username, or refresh to load recent changes.
|
||||
- Open **Account** to change your login username, which is also the name shown to the group. Login usernames must be unique. Your preferences and active sessions remain tied to your account when you rename it. You can also permanently delete your account, preferences, and sessions by typing your login username to confirm.
|
||||
|
||||
This version uses a new database schema and does not migrate earlier preproduction data. Wipe the old database or Docker volume before starting it with existing data.
|
||||
Databases from the version with separate display names are migrated automatically; existing login usernames become the names shown to the group. Databases using the earlier preproduction username-only schema still require a fresh database.
|
||||
|
||||
The shared password does not verify who owns a username: anyone who knows it can sign in using another person's username. If this is exposed beyond a trusted group, serve it over HTTPS and set `COOKIE_SECURE=1`.
|
||||
|
||||
|
|
|
|||
44
server.py
44
server.py
|
|
@ -71,7 +71,6 @@ def user_payload(row: sqlite3.Row) -> dict:
|
|||
return {
|
||||
"userId": row["user_id"],
|
||||
"loginUsername": row["login_username"],
|
||||
"displayName": row["display_name"],
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -87,9 +86,11 @@ def suggested_usernames(query: str, connection: sqlite3.Connection) -> list[str]
|
|||
matches = []
|
||||
for row in connection.execute("SELECT login_key, login_username FROM users"):
|
||||
candidate = row["login_key"]
|
||||
if candidate == key:
|
||||
continue
|
||||
similarity = SequenceMatcher(None, key, candidate).ratio()
|
||||
if candidate == key or candidate.startswith(key) or key.startswith(candidate) or similarity >= 0.64:
|
||||
score = (candidate == key, candidate.startswith(key), similarity)
|
||||
if candidate.startswith(key) or key.startswith(candidate) or similarity >= 0.64:
|
||||
score = (candidate.startswith(key), similarity)
|
||||
matches.append((score, row["login_username"]))
|
||||
matches.sort(key=lambda match: (match[0], match[1].casefold()), reverse=True)
|
||||
return [username for _, username in matches[:5]]
|
||||
|
|
@ -123,7 +124,6 @@ def init_db() -> None:
|
|||
user_id INTEGER PRIMARY KEY,
|
||||
login_username TEXT NOT NULL,
|
||||
login_key TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
|
|
@ -140,6 +140,8 @@ def init_db() -> None:
|
|||
);
|
||||
"""
|
||||
)
|
||||
if "display_name" in {row["name"] for row in existing}:
|
||||
connection.execute("ALTER TABLE users DROP COLUMN display_name")
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
|
|
@ -188,7 +190,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||
token_hash = hashlib.sha256(token.encode("ascii", "ignore")).hexdigest()
|
||||
with db() as connection:
|
||||
row = connection.execute(
|
||||
"""SELECT u.user_id, u.login_username, u.display_name FROM sessions s
|
||||
"""SELECT u.user_id, u.login_username FROM sessions s
|
||||
JOIN users u ON u.user_id = s.user_id
|
||||
WHERE s.token_hash = ? AND s.expires_at > ?""",
|
||||
(token_hash, now().isoformat()),
|
||||
|
|
@ -234,10 +236,10 @@ class Handler(BaseHTTPRequestHandler):
|
|||
(user_id,),
|
||||
).fetchall()
|
||||
people_rows = connection.execute(
|
||||
"""SELECT u.user_id, u.login_username, u.display_name,
|
||||
"""SELECT u.user_id, u.login_username,
|
||||
p.property_id, p.preference_json, p.updated_at
|
||||
FROM users u LEFT JOIN preferences p ON p.user_id = u.user_id
|
||||
ORDER BY u.display_name COLLATE NOCASE, u.login_username COLLATE NOCASE"""
|
||||
ORDER BY u.login_username COLLATE NOCASE"""
|
||||
).fetchall()
|
||||
own = {
|
||||
row["property_id"]: preference_payload(row["property_id"], row["preference_json"], row["updated_at"])
|
||||
|
|
@ -314,11 +316,11 @@ class Handler(BaseHTTPRequestHandler):
|
|||
})
|
||||
return
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO users (login_username, login_key, display_name, created_at) VALUES (?, ?, ?, ?)",
|
||||
(username, key, username, now().isoformat()),
|
||||
"INSERT OR IGNORE INTO users (login_username, login_key, created_at) VALUES (?, ?, ?)",
|
||||
(username, key, now().isoformat()),
|
||||
)
|
||||
user = connection.execute(
|
||||
"SELECT user_id, login_username, display_name FROM users WHERE login_key = ?", (key,)
|
||||
"SELECT user_id, login_username FROM users WHERE login_key = ?", (key,)
|
||||
).fetchone()
|
||||
connection.execute(
|
||||
"INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (?, ?, ?)",
|
||||
|
|
@ -365,8 +367,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||
body = self._body()
|
||||
if body is None:
|
||||
return
|
||||
if body.get("confirmDisplayName") != session["display_name"]:
|
||||
self._json(HTTPStatus.BAD_REQUEST, {"error": "Type your display name exactly to confirm deletion."})
|
||||
if body.get("confirmLoginUsername") != session["login_username"]:
|
||||
self._json(HTTPStatus.BAD_REQUEST, {"error": "Type your login username exactly to confirm deletion."})
|
||||
return
|
||||
with db() as connection:
|
||||
connection.execute("DELETE FROM users WHERE user_id = ?", (session["user_id"],))
|
||||
|
|
@ -387,24 +389,22 @@ class Handler(BaseHTTPRequestHandler):
|
|||
if body is None:
|
||||
return
|
||||
login_username = body.get("loginUsername")
|
||||
display_name = body.get("displayName")
|
||||
if not isinstance(login_username, str) or not isinstance(display_name, str):
|
||||
self._json(HTTPStatus.BAD_REQUEST, {"error": "Enter a login username and display name."})
|
||||
if not isinstance(login_username, str):
|
||||
self._json(HTTPStatus.BAD_REQUEST, {"error": "Enter a login username."})
|
||||
return
|
||||
login_username = login_username.strip()
|
||||
display_name = display_name.strip()
|
||||
if not valid_account_name(login_username) or not valid_account_name(display_name):
|
||||
self._json(HTTPStatus.BAD_REQUEST, {"error": "Each name must be 1–32 printable characters."})
|
||||
if not valid_account_name(login_username):
|
||||
self._json(HTTPStatus.BAD_REQUEST, {"error": "Username must be 1–32 printable characters."})
|
||||
return
|
||||
try:
|
||||
with db() as connection:
|
||||
connection.execute(
|
||||
"""UPDATE users SET login_username = ?, login_key = ?, display_name = ?
|
||||
"""UPDATE users SET login_username = ?, login_key = ?
|
||||
WHERE user_id = ?""",
|
||||
(login_username, login_key_for(login_username), display_name, session["user_id"]),
|
||||
(login_username, login_key_for(login_username), session["user_id"]),
|
||||
)
|
||||
user = connection.execute(
|
||||
"SELECT user_id, login_username, display_name FROM users WHERE user_id = ?",
|
||||
"SELECT user_id, login_username FROM users WHERE user_id = ?",
|
||||
(session["user_id"],),
|
||||
).fetchone()
|
||||
except sqlite3.IntegrityError:
|
||||
|
|
@ -442,7 +442,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||
or len(value) != len(set(value))
|
||||
or not set(value).issubset(categories)
|
||||
):
|
||||
self._json(HTTPStatus.BAD_REQUEST, {"error": "Rank each chosen category once; leave excluded categories out."})
|
||||
self._json(HTTPStatus.BAD_REQUEST, {"error": "Rank each chosen category once; leave other categories unranked."})
|
||||
return
|
||||
updated_at = now().isoformat()
|
||||
with db() as connection:
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ const state = {
|
|||
view: "mine",
|
||||
propertyId: "class",
|
||||
drafts: {},
|
||||
sort: { key: "displayName", direction: 1 },
|
||||
sort: { key: "loginUsername", direction: 1 },
|
||||
groupMode: "rank",
|
||||
search: "",
|
||||
busy: false,
|
||||
|
|
@ -62,7 +62,7 @@ function propertyTabs() {
|
|||
}
|
||||
|
||||
function shell(content) {
|
||||
const username = state.data.user.displayName;
|
||||
const username = state.data.user.loginUsername;
|
||||
app.innerHTML = `<div class="site-shell">
|
||||
<header class="topbar">
|
||||
<div class="brand"><div class="brand-text"><strong>Bravo Team</strong><small>Forever Interest</small></div></div>
|
||||
|
|
@ -70,11 +70,12 @@ function shell(content) {
|
|||
</header>
|
||||
<main class="main">
|
||||
<div class="page-heading"><div><h1>Share your preferences</h1><p>Rank your class and role choices, and tell the group which content you would play. You can update your choices any time.</p></div><div class="heading-sigil circle-icon" aria-hidden="true"><img src="/icon.webp" alt=""></div></div>
|
||||
<nav class="main-nav" aria-label="Main navigation">
|
||||
<nav class="main-nav ${state.view === "account" ? "account-nav" : ""}" aria-label="Main navigation">
|
||||
<button type="button" class="nav-item ${state.view === "mine" ? "active" : ""}" data-view="mine"><span class="nav-icon">◇</span> My preferences</button>
|
||||
<button type="button" class="nav-item ${state.view === "group" ? "active" : ""}" data-view="group"><span class="nav-icon">▦</span> Group preferences <span class="nav-count">${state.data.people.length}</span></button>
|
||||
<button type="button" class="nav-item ${state.view === "account" ? "active" : ""}" data-view="account"><span class="nav-icon">○</span> Account</button>
|
||||
</nav>
|
||||
${state.view === "account" ? "" : propertyTabs()}
|
||||
${state.notice ? `<div class="notice" role="status">${escapeHtml(state.notice)}</div>` : ""}
|
||||
${content}
|
||||
</main>
|
||||
|
|
@ -88,7 +89,7 @@ function shell(content) {
|
|||
}));
|
||||
app.querySelectorAll("[data-property]").forEach((button) => button.addEventListener("click", () => {
|
||||
state.propertyId = button.dataset.property;
|
||||
state.sort = { key: "displayName", direction: 1 };
|
||||
state.sort = { key: "loginUsername", direction: 1 };
|
||||
state.notice = "";
|
||||
render();
|
||||
}));
|
||||
|
|
@ -211,16 +212,14 @@ function renderEditor() {
|
|||
<button type="button" class="move-button" data-move="up" data-index="${index}" aria-label="Move ${escapeHtml(category)} up" ${index === 0 ? "disabled" : ""}>↑</button>
|
||||
<button type="button" class="move-button" data-move="down" data-index="${index}" aria-label="Move ${escapeHtml(category)} down" ${index === draft.length - 1 ? "disabled" : ""}>↓</button>
|
||||
<button type="button" class="drag-handle" data-index="${index}" aria-label="Drag ${escapeHtml(category)} to reorder; use arrow keys when focused" title="Drag to reorder">⠿</button>
|
||||
<button type="button" class="exclude-button" data-exclude="${escapeHtml(category)}" aria-label="Exclude ${escapeHtml(category)} from your ranking" title="No preference for ${escapeHtml(category)}">×</button>
|
||||
<button type="button" class="exclude-button" data-exclude="${escapeHtml(category)}" aria-label="Leave ${escapeHtml(category)} unranked" title="No preference for ${escapeHtml(category)}">×</button>
|
||||
</div>
|
||||
</li>`).join("");
|
||||
const excludedCards = excluded.map((category) => `<li class="excluded-card"><span class="category-name">${escapeHtml(category)}</span><span class="excluded-tag">NO PREFERENCE</span><button type="button" class="restore-button" data-restore="${escapeHtml(category)}" aria-label="Restore ${escapeHtml(category)} to your ranking">Restore</button></li>`).join("");
|
||||
const excludedCards = excluded.map((category) => `<li class="excluded-card"><span class="category-name">${escapeHtml(category)}</span><span class="excluded-tag">NO PREFERENCE</span><button type="button" class="restore-button" data-restore="${escapeHtml(category)}" aria-label="Rank ${escapeHtml(category)}">Rank</button></li>`).join("");
|
||||
shell(`<section class="content-card editor-card">
|
||||
<div class="card-topline"><span class="section-kicker">01 / YOUR RANKING</span><span class="status ${status === "Saved" ? "saved" : status === "Unsaved changes" ? "changed" : ""}"><span class="status-dot"></span>${status}</span></div>
|
||||
<div class="section-heading"><div><h2>Rank your ${escapeHtml(item.name.toLowerCase())} choices</h2><p>Put your favorites at the top. Select × for any choice you do not want to rank; you can restore it below.</p></div><div class="count-badge">${draft.length} RANKED</div></div>
|
||||
${propertyTabs()}
|
||||
<div class="ranking-layout"><div class="ranking-labels"><span>MOST PREFERRED</span><span>DRAG TO ORDER</span></div><ol class="rank-list" id="rank-list">${cards}</ol>${draft.length ? `<div class="least-label">LOWEST RANKED</div>` : `<p class="rank-empty">No ranked choices. Restore a category below to rank it.</p>`}
|
||||
<div class="excluded-section"><div class="excluded-heading"><strong>No preference</strong><span>${excluded.length} EXCLUDED</span></div><p>Excluded choices have no rank. They appear blank in group tables and sort as the lowest preference.</p>${excluded.length ? `<ul class="excluded-list">${excludedCards}</ul>` : `<p class="excluded-empty">No categories excluded.</p>`}</div>
|
||||
<div class="card-topline"><span class="status ${status === "Saved" ? "saved" : status === "Unsaved changes" ? "changed" : ""}"><span class="status-dot"></span>${status}</span></div>
|
||||
<div class="ranking-layout"><div class="ranking-labels"><span>HIGHEST RANKED</span><span>DRAG TO ORDER</span></div><ol class="rank-list" id="rank-list">${cards}</ol>${draft.length ? `<div class="least-label">LOWEST RANKED</div>` : `<p class="rank-empty">No ranked choices. Rank a category below to add it.</p>`}
|
||||
<div class="excluded-section"><div class="excluded-heading"><strong>No preference</strong><span>${excluded.length} UNRANKED</span></div><p>Unranked choices have no preference. They appear blank in group tables and sort after ranked choices.</p>${excluded.length ? `<ul class="excluded-list">${excludedCards}</ul>` : `<p class="excluded-empty">All categories are ranked.</p>`}</div>
|
||||
</div>
|
||||
<div class="card-footer"><span>${saved ? `Last saved ${formatDate(state.data.preferences[item.id].updatedAt)}` : "Save to share your ranking with the group."}</span><button type="button" class="primary-button" id="save" ${state.busy ? "disabled" : ""}>${state.busy ? "Saving…" : saved ? "Update ranking" : "Save ranking"} <span>→</span></button></div>
|
||||
</section>`);
|
||||
|
|
@ -261,9 +260,7 @@ function renderContentEditor() {
|
|||
</div>
|
||||
</div>`).join("");
|
||||
shell(`<section class="content-card editor-card">
|
||||
<div class="card-topline"><span class="section-kicker">01 / YOUR CONTENT</span><span class="status ${status === "Saved" ? "saved" : status === "Unsaved changes" ? "changed" : ""}"><span class="status-dot"></span>${status}</span></div>
|
||||
<div class="section-heading"><div><h2>Choose your content interests</h2><p>Select Yes, Maybe, or No for each activity.</p></div><div class="count-badge">${answered} / ${item.categories.length} ANSWERED</div></div>
|
||||
${propertyTabs()}
|
||||
<div class="card-topline"><span class="status ${status === "Saved" ? "saved" : status === "Unsaved changes" ? "changed" : ""}"><span class="status-dot"></span>${status}</span></div>
|
||||
<div class="content-list">${rows}</div>
|
||||
<div class="card-footer"><span>${saved ? `Last saved ${formatDate(state.data.preferences.content.updatedAt)}` : `${answered} of ${item.categories.length} answered. Choose one response for each activity.`}</span><button type="button" class="primary-button" id="save" ${state.busy || answered !== item.categories.length ? "disabled" : ""}>${state.busy ? "Saving…" : saved ? "Update content" : "Save content"} <span>→</span></button></div>
|
||||
</section>`);
|
||||
|
|
@ -358,17 +355,15 @@ function formatDate(value) {
|
|||
}
|
||||
|
||||
function renderAccount() {
|
||||
const { loginUsername, displayName } = state.data.user;
|
||||
const { loginUsername } = state.data.user;
|
||||
shell(`<section class="content-card account-card">
|
||||
<div class="card-topline"><span class="section-kicker">03 / YOUR ACCOUNT</span></div>
|
||||
<div class="section-heading"><div><h2>Account settings</h2><p>Change the name you sign in with or the name shown to the group.</p></div></div>
|
||||
<div class="section-heading"><div><h2>Account settings</h2><p>Change the name you sign in with and show to the group.</p></div></div>
|
||||
<form class="account-details" id="account-form">
|
||||
<div class="account-fields"><div><label for="login-username">LOGIN USERNAME</label><input id="login-username" name="loginUsername" type="text" maxlength="32" autocomplete="username" value="${escapeHtml(loginUsername)}" required><p>Unique, matched without regard to capitalization.</p></div>
|
||||
<div><label for="display-name">DISPLAY NAME</label><input id="display-name" name="displayName" type="text" maxlength="32" value="${escapeHtml(displayName)}" required><p>Shown in the group tables. Display names may be shared.</p></div></div>
|
||||
<div class="account-fields"><div><label for="login-username">LOGIN USERNAME</label><input id="login-username" name="loginUsername" type="text" maxlength="32" autocomplete="username" value="${escapeHtml(loginUsername)}" required><p>Shown to the group. Unique, matched without regard to capitalization.</p></div></div>
|
||||
<div class="account-save"><div class="form-error" role="alert"></div><button class="primary-button" type="submit">Save account changes <span>→</span></button></div>
|
||||
</form>
|
||||
<div class="danger-zone"><div><span class="danger-kicker">DELETE ACCOUNT</span><h3>Remove yourself from the roster</h3><p>This permanently deletes your account, all saved preferences, and all active sessions. Type <strong>${escapeHtml(displayName)}</strong> exactly to confirm.</p></div>
|
||||
<form id="delete-form"><label for="confirm-display-name">YOUR DISPLAY NAME</label><input id="confirm-display-name" type="text" autocomplete="off" placeholder="Type ${escapeHtml(displayName)}" required><div class="form-error" id="delete-error" role="alert"></div><button class="delete-button" type="submit" disabled>Delete my account</button></form>
|
||||
<div class="danger-zone"><div><span class="danger-kicker">DELETE ACCOUNT</span><h3>Remove yourself from the roster</h3><p>This permanently deletes your account, all saved preferences, and all active sessions. Type <strong>${escapeHtml(loginUsername)}</strong> exactly to confirm.</p></div>
|
||||
<form id="delete-form"><label for="confirm-login-username">YOUR LOGIN USERNAME</label><input id="confirm-login-username" type="text" autocomplete="off" placeholder="Type ${escapeHtml(loginUsername)}" required><div class="form-error" id="delete-error" role="alert"></div><button class="delete-button" type="submit" disabled>Delete my account</button></form>
|
||||
</div>
|
||||
</section>`);
|
||||
const accountForm = app.querySelector("#account-form");
|
||||
|
|
@ -382,7 +377,6 @@ function renderAccount() {
|
|||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
loginUsername: accountForm.elements.namedItem("loginUsername").value,
|
||||
displayName: accountForm.elements.namedItem("displayName").value,
|
||||
}),
|
||||
});
|
||||
await load(true);
|
||||
|
|
@ -394,18 +388,18 @@ function renderAccount() {
|
|||
}
|
||||
});
|
||||
const form = app.querySelector("#delete-form");
|
||||
const input = form.querySelector("#confirm-display-name");
|
||||
const input = form.querySelector("#confirm-login-username");
|
||||
const button = form.querySelector('button[type="submit"]');
|
||||
input.addEventListener("input", () => {
|
||||
button.disabled = input.value !== displayName;
|
||||
button.disabled = input.value !== loginUsername;
|
||||
form.querySelector("#delete-error").textContent = "";
|
||||
});
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (input.value !== displayName) return;
|
||||
if (input.value !== loginUsername) return;
|
||||
button.disabled = true;
|
||||
try {
|
||||
await api("/api/me", { method: "DELETE", body: JSON.stringify({ confirmDisplayName: input.value }) });
|
||||
await api("/api/me", { method: "DELETE", body: JSON.stringify({ confirmLoginUsername: input.value }) });
|
||||
state.data = null;
|
||||
state.drafts = {};
|
||||
state.view = "mine";
|
||||
|
|
@ -427,7 +421,7 @@ function sortPeople(people, propertyId) {
|
|||
const { key, direction } = state.sort;
|
||||
return [...people].sort((a, b) => {
|
||||
const value = (person) => {
|
||||
if (key === "displayName") return person.displayName;
|
||||
if (key === "loginUsername") return person.loginUsername;
|
||||
const preference = person.preferences[propertyId];
|
||||
if (key === "updated") return preference?.updatedAt;
|
||||
if (key.startsWith("category:")) {
|
||||
|
|
@ -466,7 +460,7 @@ function renderGroup() {
|
|||
const isContent = item.id === "content";
|
||||
const query = state.search.trim().toLocaleLowerCase();
|
||||
const people = sortPeople(state.data.people.filter((person) =>
|
||||
person.displayName.toLocaleLowerCase().includes(query) || person.loginUsername.toLocaleLowerCase().includes(query)
|
||||
person.loginUsername.toLocaleLowerCase().includes(query)
|
||||
), item.id);
|
||||
const submitted = state.data.people.filter((person) => person.preferences[item.id]).length;
|
||||
const columns = !isContent && state.groupMode === "rank"
|
||||
|
|
@ -487,19 +481,17 @@ function renderGroup() {
|
|||
const value = column.category ? ordinal(rank) : preference.ranking[column.index];
|
||||
return `<td><span class="table-choice ${rank === 1 ? "first" : ""}">${escapeHtml(value)}</span></td>`;
|
||||
}).join("");
|
||||
return `<tr><th scope="row"><div class="person-cell"><span class="person-name">${escapeHtml(person.displayName)}${person.userId === state.data.user.userId ? `<small>YOU</small>` : ""}</span>${person.loginUsername !== person.displayName ? `<span class="person-login">@${escapeHtml(person.loginUsername)}</span>` : ""}</div></th>${ranks}<td class="date-cell">${preference ? formatDate(preference.updatedAt) : `<span class="pending">Pending</span>`}</td></tr>`;
|
||||
return `<tr><th scope="row"><div class="person-cell"><span class="person-name">${escapeHtml(person.loginUsername)}${person.userId === state.data.user.userId ? `<small>YOU</small>` : ""}</span></div></th>${ranks}<td class="date-cell">${preference ? formatDate(preference.updatedAt) : `<span class="pending">Pending</span>`}</td></tr>`;
|
||||
}).join("");
|
||||
shell(`<section class="content-card group-card">
|
||||
<div class="card-topline"><span class="section-kicker">02 / THE GROUP</span><span class="group-total">${submitted} OF ${state.data.people.length} ${isContent ? "RESPONDED" : "RANKED"}</span></div>
|
||||
<div class="section-heading"><div><h2>Everyone’s preferences</h2><p>Explore the group’s choices. Select any column heading to sort.</p></div></div>
|
||||
${propertyTabs()}
|
||||
<div class="card-topline"><span class="group-total">${submitted} OF ${state.data.people.length} ${isContent ? "RESPONDED" : "RANKED"}</span></div>
|
||||
<div class="table-tools"><div class="table-description"><strong>${escapeHtml(item.name)} ${isContent ? "preferences" : "rankings"}</strong><span>${people.length} ${people.length === 1 ? "person" : "people"} shown</span></div><div class="table-actions">${isContent ? "" : `<div class="view-toggle" role="group" aria-label="Table columns"><button type="button" data-group-mode="rank" aria-pressed="${state.groupMode === "rank"}" class="${state.groupMode === "rank" ? "selected" : ""}">By rank</button><button type="button" data-group-mode="category" aria-pressed="${state.groupMode === "category"}" class="${state.groupMode === "category" ? "selected" : ""}">By category</button></div>`}<label class="search-box"><span aria-hidden="true">⌕</span><input id="search" type="search" placeholder="Find a person" value="${escapeHtml(state.search)}" aria-label="Find a person"></label><button type="button" class="secondary-button" id="refresh">Refresh</button></div></div>
|
||||
<div class="table-scroll"><table><thead><tr><th scope="col" aria-sort="${state.sort.key === "displayName" ? (state.sort.direction === 1 ? "ascending" : "descending") : "none"}">${sortHeader("displayName", "PERSON")}</th>${headers}<th scope="col" aria-sort="${state.sort.key === "updated" ? (state.sort.direction === 1 ? "ascending" : "descending") : "none"}">${sortHeader("updated", "UPDATED")}</th></tr></thead><tbody>${rows || `<tr><td class="empty-table" colspan="${item.categories.length + 2}">No people match that search.</td></tr>`}</tbody></table></div>
|
||||
<div class="table-footer">${isContent ? "Only saved preferences appear here. A dash means the person has not completed this property yet." : "Only saved preferences appear here. A blank cell means no preference was ranked; a dash means the person has not submitted this property."}</div>
|
||||
<div class="table-scroll"><table><thead><tr><th scope="col" aria-sort="${state.sort.key === "loginUsername" ? (state.sort.direction === 1 ? "ascending" : "descending") : "none"}">${sortHeader("loginUsername", "PERSON")}</th>${headers}<th scope="col" aria-sort="${state.sort.key === "updated" ? (state.sort.direction === 1 ? "ascending" : "descending") : "none"}">${sortHeader("updated", "UPDATED")}</th></tr></thead><tbody>${rows || `<tr><td class="empty-table" colspan="${item.categories.length + 2}">No people match that search.</td></tr>`}</tbody></table></div>
|
||||
<div class="table-footer">${isContent ? "Only saved preferences appear here. A dash means the person has not completed this property yet." : "Only saved preferences appear here. A blank cell means no choice was ranked there; a dash means the person has not submitted a ranking for this property."}</div>
|
||||
</section>`);
|
||||
app.querySelectorAll("[data-group-mode]").forEach((button) => button.addEventListener("click", () => {
|
||||
state.groupMode = button.dataset.groupMode;
|
||||
state.sort = { key: "displayName", direction: 1 };
|
||||
state.sort = { key: "loginUsername", direction: 1 };
|
||||
render();
|
||||
}));
|
||||
app.querySelectorAll("[data-sort]").forEach((button) => button.addEventListener("click", () => {
|
||||
|
|
|
|||
|
|
@ -39,7 +39,8 @@ h1, h2, h3, p { margin-top: 0; }
|
|||
.circle-icon { display: grid; place-items: center; overflow: hidden; border: 1px solid #8c744d; border-radius: 50%; background: #e8e5dd; box-shadow: 0 0 0 4px #b9954517; }
|
||||
.circle-icon img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.heading-sigil { width: 74px; height: 74px; flex: 0 0 74px; }
|
||||
.main-nav { display: flex; gap: 5px; border-bottom: 1px solid #33404b; margin-bottom: 24px; }
|
||||
.main-nav { display: flex; gap: 5px; border-bottom: 1px solid #33404b; }
|
||||
.main-nav.account-nav { margin-bottom: 24px; }
|
||||
.nav-item { border: 0; border-bottom: 2px solid transparent; background: none; padding: 17px 22px 18px; margin-bottom: -1px; color: #a4afb9; font-weight: 600; font-size: 19px; }
|
||||
.nav-item.active { color: #f4e3bd; border-color: #d6aa59; }
|
||||
.nav-item:hover { color: #fff; }
|
||||
|
|
@ -48,7 +49,6 @@ h1, h2, h3, p { margin-top: 0; }
|
|||
.notice { border: 1px solid #856c3a; background: #2a2a25; color: #f2d598; padding: 12px 16px; border-radius: 5px; font-size: 19px; margin-bottom: 16px; }
|
||||
.content-card { background: #142231; border: 1px solid #31404d; border-radius: 7px; box-shadow: 0 20px 75px rgba(0,0,0,.16); }
|
||||
.card-topline { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #2c3b48; min-height: 53px; padding: 0 31px; }
|
||||
.section-kicker { color: #c9a866; font-size: 15px; letter-spacing: .2em; font-weight: 700; }
|
||||
.status, .group-total { color: #9eabb5; font-size: 16px; font-weight: 700; letter-spacing: .09em; text-transform: uppercase; }
|
||||
.status { display: flex; align-items: center; gap: 8px; }
|
||||
.status-dot { width: 7px; height: 7px; border-radius: 50%; background: #77818a; }
|
||||
|
|
@ -57,8 +57,7 @@ h1, h2, h3, p { margin-top: 0; }
|
|||
.section-heading { padding: 30px 31px 20px; display: flex; justify-content: space-between; align-items: center; gap: 15px; }
|
||||
.section-heading h2 { font: 600 25px/1.3 'Cinzel', Georgia, serif; color: #f1ebdd; letter-spacing: -.015em; margin-bottom: 7px; }
|
||||
.section-heading p { color: #9facb7; font-size: 19px; line-height: 1.55; margin: 0; }
|
||||
.count-badge { white-space: nowrap; border: 1px solid #46505a; color: #bfaa7d; padding: 8px 11px; font-size: 15px; font-weight: 700; letter-spacing: .12em; }
|
||||
.property-tabs { display: flex; overflow-x: auto; border-bottom: 1px solid #35434e; padding: 0 31px; gap: 25px; }
|
||||
.property-tabs { display: flex; overflow-x: auto; border-bottom: 1px solid #35434e; padding: 0 31px; gap: 25px; margin: 12px 0; }
|
||||
.property-tab { padding: 13px 8px 14px; border: 0; border-bottom: 2px solid transparent; background: none; color: #9eacb6; font-size: 19px; font-weight: 700; margin-bottom: -1px; white-space: nowrap; }
|
||||
.property-tab.selected { color: #f0d6a0; border-color: #d8ac61; }
|
||||
.property-tab:hover { color: #fff; }
|
||||
|
|
@ -139,7 +138,6 @@ tbody tr:hover td, tbody tr:hover th { background: #203242; }
|
|||
.sort-button span { font-size: 14px; color: #bc9d65; }
|
||||
.person-cell { display: grid; gap: 3px; }
|
||||
.person-name { color: #e8e9e7; }
|
||||
.person-login { color: #91a2af; font-size: 15px; font-weight: 400; }
|
||||
.person-cell small { display: inline-block; font-size: 12px; letter-spacing: .08em; color: #d8b879; margin-left: 7px; }
|
||||
.table-choice { color: #cbd3d7; }
|
||||
.table-choice.first { color: #f0cf8d; font-weight: 700; }
|
||||
|
|
@ -154,7 +152,7 @@ tbody tr:hover td, tbody tr:hover th { background: #203242; }
|
|||
.table-footer { padding: 17px 31px; color: #8d9da9; font-size: 16px; }
|
||||
|
||||
.account-details { margin: 0 31px 28px; padding: 22px 20px; background: #1b2c3a; border: 1px solid #394957; display: grid; gap: 20px; }
|
||||
.account-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
|
||||
.account-fields { max-width: 420px; }
|
||||
.account-details label, .danger-kicker { display: block; font-size: 15px; letter-spacing: .16em; font-weight: 700; color: #c9a866; margin-bottom: 8px; }
|
||||
.account-details input { width: 100%; height: 50px; border: 1px solid #52606a; background: #122230; color: #f2f0e8; padding: 0 13px; outline: none; border-radius: 2px; font-size: 19px; }
|
||||
.account-details input:focus { border-color: #d9b674; }
|
||||
|
|
@ -223,7 +221,6 @@ tbody tr:hover td, tbody tr:hover th { background: #203242; }
|
|||
.property-tabs { padding: 0 17px; }
|
||||
.section-heading h2 { font-size: 19px; }
|
||||
.section-heading p { font-size: 16px; }
|
||||
.count-badge { display: none; }
|
||||
.card-footer { flex-direction: column; align-items: stretch; }
|
||||
.card-footer .primary-button { align-self: stretch; }
|
||||
.table-tools { align-items: stretch; flex-direction: column; }
|
||||
|
|
@ -242,7 +239,6 @@ tbody tr:hover td, tbody tr:hover th { background: #203242; }
|
|||
.search-box { flex: 1; }
|
||||
.search-box input { width: 100%; }
|
||||
.account-details { margin-left: 17px; margin-right: 17px; }
|
||||
.account-fields { grid-template-columns: 1fr; }
|
||||
.account-save { flex-direction: column; align-items: stretch; }
|
||||
.danger-zone { padding-left: 17px; padding-right: 17px; }
|
||||
.login-card { padding: 29px 25px; }
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ class ServerTest(unittest.TestCase):
|
|||
table: [(row["from"], row["to"]) for row in connection.execute(f"PRAGMA foreign_key_list({table})")]
|
||||
for table in ("sessions", "preferences")
|
||||
}
|
||||
self.assertEqual(columns["users"], ["user_id", "login_username", "login_key", "display_name", "created_at"])
|
||||
self.assertEqual(columns["users"], ["user_id", "login_username", "login_key", "created_at"])
|
||||
self.assertEqual(columns["sessions"], ["token_hash", "user_id", "expires_at"])
|
||||
self.assertEqual(columns["preferences"], ["user_id", "property_id", "preference_json", "updated_at"])
|
||||
self.assertEqual(foreign_keys["sessions"], [("user_id", "user_id")])
|
||||
|
|
@ -75,6 +75,39 @@ class ServerTest(unittest.TestCase):
|
|||
server.DB_PATH = current_db_path
|
||||
connection.close()
|
||||
|
||||
def test_separate_display_names_migrate_without_losing_accounts(self):
|
||||
old_path = Path(self.tempdir.name) / "separate-names.sqlite3"
|
||||
current_db_path = server.DB_PATH
|
||||
connection = sqlite3.connect(old_path)
|
||||
try:
|
||||
connection.execute("""CREATE TABLE users (
|
||||
user_id INTEGER PRIMARY KEY, login_username TEXT NOT NULL,
|
||||
login_key TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL)""")
|
||||
connection.execute("""CREATE TABLE sessions (
|
||||
token_hash TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
expires_at TEXT NOT NULL)""")
|
||||
connection.execute("""CREATE TABLE preferences (
|
||||
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
property_id TEXT NOT NULL, preference_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL, PRIMARY KEY (user_id, property_id))""")
|
||||
connection.execute("INSERT INTO users VALUES (1, 'Aly', 'aly', 'Other Name', 'today')")
|
||||
connection.execute("INSERT INTO sessions VALUES ('token', 1, 'later')")
|
||||
connection.execute("INSERT INTO preferences VALUES (1, 'role', '[\"Tank\"]', 'today')")
|
||||
connection.commit()
|
||||
server.DB_PATH = old_path
|
||||
server.init_db()
|
||||
self.assertEqual(
|
||||
[row[1] for row in connection.execute("PRAGMA table_info(users)")],
|
||||
["user_id", "login_username", "login_key", "created_at"],
|
||||
)
|
||||
self.assertEqual(connection.execute("SELECT login_username FROM users").fetchone()[0], "Aly")
|
||||
self.assertEqual(connection.execute("SELECT user_id FROM sessions").fetchone()[0], 1)
|
||||
self.assertEqual(connection.execute("SELECT preference_json FROM preferences").fetchone()[0], '["Tank"]')
|
||||
finally:
|
||||
server.DB_PATH = current_db_path
|
||||
connection.close()
|
||||
|
||||
def test_login_save_and_group_view(self):
|
||||
status, data, _ = self.request("GET", "/api/bootstrap")
|
||||
self.assertEqual(status, 200)
|
||||
|
|
@ -87,7 +120,7 @@ class ServerTest(unittest.TestCase):
|
|||
status, data, cookie_header = self.request("POST", "/api/login", {"loginUsername": "Aly", "password": "galnik"})
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data["user"]["loginUsername"], "Aly")
|
||||
self.assertEqual(data["user"]["displayName"], "Aly")
|
||||
self.assertNotIn("displayName", data["user"])
|
||||
aly_id = data["user"]["userId"]
|
||||
cookie = cookie_header.split(";", 1)[0]
|
||||
|
||||
|
|
@ -130,22 +163,23 @@ class ServerTest(unittest.TestCase):
|
|||
status, _, second_cookie_header = self.request("POST", "/api/login", {"loginUsername": "aly", "password": "galnik"})
|
||||
self.assertEqual(status, 200)
|
||||
second_cookie = second_cookie_header.split(";", 1)[0]
|
||||
status, data, _ = self.request("PUT", "/api/me", {"loginUsername": "Aly", "displayName": "Bex"}, cookie)
|
||||
status, data, _ = self.request("PUT", "/api/me", {"loginUsername": "Aly"}, cookie)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data["user"]["userId"], aly_id)
|
||||
status, data, _ = self.request("GET", "/api/bootstrap", cookie=cookie)
|
||||
self.assertEqual(len(data["people"]), 2)
|
||||
self.assertEqual([person["displayName"] for person in data["people"]], ["Bex", "Bex"])
|
||||
self.assertEqual([person["loginUsername"] for person in data["people"]], ["Aly", "Bex"])
|
||||
self.assertTrue(all("displayName" not in person for person in data["people"]))
|
||||
self.assertEqual(len({person["userId"] for person in data["people"]}), 2)
|
||||
|
||||
status, data, _ = self.request("PUT", "/api/me", {"loginUsername": "BEX", "displayName": "Bex"}, cookie)
|
||||
status, data, _ = self.request("PUT", "/api/me", {"loginUsername": "BEX"}, cookie)
|
||||
self.assertEqual(status, 409)
|
||||
status, data, _ = self.request("PUT", "/api/me", {"loginUsername": "AlyNew", "displayName": "Bex"}, cookie)
|
||||
status, data, _ = self.request("PUT", "/api/me", {"loginUsername": "AlyNew"}, cookie)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data["user"]["userId"], aly_id)
|
||||
status, data, _ = self.request("GET", "/api/bootstrap", cookie=second_cookie)
|
||||
self.assertEqual(data["user"]["loginUsername"], "AlyNew")
|
||||
self.assertEqual(data["user"]["displayName"], "Bex")
|
||||
self.assertNotIn("displayName", data["user"])
|
||||
self.assertEqual(data["preferences"]["role"]["ranking"], ranking)
|
||||
self.assertEqual(data["preferences"]["content"]["choices"], content_choices)
|
||||
status, data, _ = self.request("POST", "/api/login", {"loginUsername": "alynew", "password": "galnik"})
|
||||
|
|
@ -155,6 +189,11 @@ class ServerTest(unittest.TestCase):
|
|||
self.assertEqual(status, 200)
|
||||
self.assertIn("AlyNew", data["suggestions"])
|
||||
|
||||
for typed_name in ("AlyNew", "alynew"):
|
||||
status, data, _ = self.request("POST", "/api/username-suggestions", {"loginUsername": typed_name})
|
||||
self.assertEqual(status, 200)
|
||||
self.assertNotIn("AlyNew", data["suggestions"])
|
||||
|
||||
status, _, _ = self.request("POST", "/api/logout", {}, cookie)
|
||||
self.assertEqual(status, 204)
|
||||
status, data, _ = self.request("GET", "/api/bootstrap", cookie=cookie)
|
||||
|
|
@ -224,9 +263,9 @@ class ServerTest(unittest.TestCase):
|
|||
"PUT", "/api/preferences/role", {"ranking": ["DPS", "Tank", "Healer"]}, cookie
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
status, _, _ = self.request("DELETE", "/api/me", {"confirmDisplayName": "meridian"}, cookie)
|
||||
status, _, _ = self.request("DELETE", "/api/me", {"confirmLoginUsername": "meridian"}, cookie)
|
||||
self.assertEqual(status, 400)
|
||||
status, _, _ = self.request("DELETE", "/api/me", {"confirmDisplayName": "Meridian"}, cookie)
|
||||
status, _, _ = self.request("DELETE", "/api/me", {"confirmLoginUsername": "Meridian"}, cookie)
|
||||
self.assertEqual(status, 204)
|
||||
|
||||
status, _, _ = self.request("PUT", "/api/preferences/role", {"ranking": ["Tank", "Healer", "DPS"]}, cookie)
|
||||
|
|
|
|||
|
|
@ -13,24 +13,24 @@ test("category columns sort excluded choices as lowest and pending submissions l
|
|||
});
|
||||
vm.runInContext(source, context);
|
||||
context.people = [
|
||||
{ userId: 1, loginUsername: "Third", displayName: "Third", preferences: { class: { ranking: ["Mage", "Warrior", "Paladin"] } } },
|
||||
{ userId: 2, loginUsername: "First", displayName: "First", preferences: { class: { ranking: ["Paladin", "Mage", "Warrior"] } } },
|
||||
{ userId: 3, loginUsername: "Pending", displayName: "Pending", preferences: {} },
|
||||
{ userId: 4, loginUsername: "Second", displayName: "Second", preferences: { class: { ranking: ["Mage", "Paladin", "Warrior"] } } },
|
||||
{ userId: 5, loginUsername: "Excluded", displayName: "Excluded", preferences: { class: { ranking: ["Mage", "Warrior"] } } },
|
||||
{ userId: 1, loginUsername: "Third", preferences: { class: { ranking: ["Mage", "Warrior", "Paladin"] } } },
|
||||
{ userId: 2, loginUsername: "First", preferences: { class: { ranking: ["Paladin", "Mage", "Warrior"] } } },
|
||||
{ userId: 3, loginUsername: "Pending", preferences: {} },
|
||||
{ userId: 4, loginUsername: "Second", preferences: { class: { ranking: ["Mage", "Paladin", "Warrior"] } } },
|
||||
{ userId: 5, loginUsername: "Excluded", preferences: { class: { ranking: ["Mage", "Warrior"] } } },
|
||||
];
|
||||
const names = vm.runInContext(
|
||||
"state.sort = { key: 'category:Paladin', direction: 1 }; sortPeople(people, 'class').map(person => person.displayName)",
|
||||
"state.sort = { key: 'category:Paladin', direction: 1 }; sortPeople(people, 'class').map(person => person.loginUsername)",
|
||||
context,
|
||||
);
|
||||
assert.deepEqual(Array.from(names), ["First", "Second", "Third", "Excluded", "Pending"]);
|
||||
const reversed = vm.runInContext(
|
||||
"state.sort = { key: 'category:Paladin', direction: -1 }; sortPeople(people, 'class').map(person => person.displayName)",
|
||||
"state.sort = { key: 'category:Paladin', direction: -1 }; sortPeople(people, 'class').map(person => person.loginUsername)",
|
||||
context,
|
||||
);
|
||||
assert.deepEqual(Array.from(reversed), ["Excluded", "Third", "Second", "First", "Pending"]);
|
||||
const rankColumn = vm.runInContext(
|
||||
"state.sort = { key: 'rank2', direction: 1 }; sortPeople(people, 'class').map(person => person.displayName)",
|
||||
"state.sort = { key: 'rank2', direction: 1 }; sortPeople(people, 'class').map(person => person.loginUsername)",
|
||||
context,
|
||||
);
|
||||
assert.deepEqual(Array.from(rankColumn), ["Third", "First", "Second", "Excluded", "Pending"]);
|
||||
|
|
@ -51,16 +51,18 @@ test("group tables leave excluded categories and empty rank positions blank", ()
|
|||
vm.runInContext(source, context);
|
||||
vm.runInContext(`
|
||||
state.data = {
|
||||
user: { userId: 1, loginUsername: "Aly", displayName: "Aly" },
|
||||
user: { userId: 1, loginUsername: "Aly" },
|
||||
properties: [{ id: "class", name: "Class", categories: ["Paladin", "Mage", "Warrior"] }],
|
||||
preferences: { class: { ranking: ["Mage"] } },
|
||||
people: [{ userId: 1, loginUsername: "Aly", displayName: "Aly", preferences: { class: { ranking: ["Mage"], updatedAt: "2026-10-01T00:00:00Z" } } }],
|
||||
people: [{ userId: 1, loginUsername: "Aly", preferences: { class: { ranking: ["Mage"], updatedAt: "2026-10-01T00:00:00Z" } } }],
|
||||
};
|
||||
state.groupMode = "category";
|
||||
renderGroup();
|
||||
`, context);
|
||||
assert.equal((app.innerHTML.match(/aria-label="No preference"><\/td>/g) || []).length, 2);
|
||||
assert.doesNotMatch(app.innerHTML, />0th</);
|
||||
assert.match(app.innerHTML, /class="person-name">Aly<small>YOU<\/small>/);
|
||||
assert.doesNotMatch(app.innerHTML, /person-login|undefined/);
|
||||
vm.runInContext('state.groupMode = "rank"; renderGroup();', context);
|
||||
assert.equal((app.innerHTML.match(/aria-label="No ranked choice"><\/td>/g) || []).length, 2);
|
||||
});
|
||||
|
|
@ -72,13 +74,13 @@ test("content columns sort Yes, Maybe, No and leave unanswered people last", ()
|
|||
});
|
||||
vm.runInContext(source, context);
|
||||
context.people = [
|
||||
{ userId: 1, loginUsername: "Noah", displayName: "Noah", preferences: { content: { choices: { "Group Questing": "No" } } } },
|
||||
{ userId: 2, loginUsername: "Yara", displayName: "Yara", preferences: { content: { choices: { "Group Questing": "Yes" } } } },
|
||||
{ userId: 3, loginUsername: "Missing", displayName: "Missing", preferences: {} },
|
||||
{ userId: 4, loginUsername: "Mira", displayName: "Mira", preferences: { content: { choices: { "Group Questing": "Maybe" } } } },
|
||||
{ userId: 1, loginUsername: "Noah", preferences: { content: { choices: { "Group Questing": "No" } } } },
|
||||
{ userId: 2, loginUsername: "Yara", preferences: { content: { choices: { "Group Questing": "Yes" } } } },
|
||||
{ userId: 3, loginUsername: "Missing", preferences: {} },
|
||||
{ userId: 4, loginUsername: "Mira", preferences: { content: { choices: { "Group Questing": "Maybe" } } } },
|
||||
];
|
||||
const names = vm.runInContext(
|
||||
"state.sort = { key: 'category:Group Questing', direction: 1 }; sortPeople(people, 'content').map(person => person.displayName)",
|
||||
"state.sort = { key: 'category:Group Questing', direction: 1 }; sortPeople(people, 'content').map(person => person.loginUsername)",
|
||||
context,
|
||||
);
|
||||
assert.deepEqual(Array.from(names), ["Yara", "Mira", "Noah", "Missing"]);
|
||||
|
|
|
|||
Loading…
Reference in New Issue