nidus-sync/static/js/user-selector.js
Eli Ribble 31947c848a
Move static outside HTML. Start work on TypeScript bundle
It's not strictly HTML, so that's just correct.

This is just worth doing while building the new TypeScript bundle
2026-03-21 03:06:59 +00:00

243 lines
5.5 KiB
JavaScript

class UserSelector extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.selectedUser = null;
this.debounceTimer = null;
}
connectedCallback() {
this.render();
this.setupEventListeners();
}
render() {
this.shadowRoot.innerHTML = `
<link href="/static/css/bootstrap.css" rel="stylesheet">
<style>
:host {
display: block;
position: relative;
}
.suggestions-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1000;
max-height: 300px;
overflow-y: auto;
display: none;
}
.suggestions-dropdown.show {
display: block;
}
.suggestion-item {
cursor: pointer;
border-bottom: 1px solid #dee2e6;
}
.suggestion-item:last-child {
border-bottom: none;
}
.suggestion-item:hover {
background-color: #f8f9fa;
}
.user-display-name {
font-weight: 500;
color: #212529;
}
.user-username {
font-size: 0.875rem;
color: #6c757d;
}
.user-org {
font-size: 0.875rem;
color: #6c757d;
}
.loading {
text-align: center;
padding: 0.75rem;
color: #6c757d;
}
</style>
<div class="user-selector-container">
<input
type="text"
class="form-control"
placeholder="Type to search users (min. 4 characters)..."
id="userInput"
autocomplete="off"
/>
<div class="suggestions-dropdown card shadow-sm" id="suggestionsDropdown">
<div class="list-group list-group-flush" id="suggestionsList">
<!-- Suggestions will be inserted here -->
</div>
</div>
</div>
`;
}
setupEventListeners() {
const input = this.shadowRoot.getElementById("userInput");
const dropdown = this.shadowRoot.getElementById("suggestionsDropdown");
input.addEventListener("input", (e) => this.handleInput(e));
input.addEventListener("focus", (e) => {
if (e.target.value.length >= 4) {
this.handleInput(e);
}
});
// Close dropdown when clicking outside
document.addEventListener("click", (e) => {
if (!this.contains(e.target)) {
this.hideSuggestions();
}
});
}
handleInput(e) {
const query = e.target.value;
// Clear previous timer
clearTimeout(this.debounceTimer);
if (query.length < 4) {
this.hideSuggestions();
return;
}
// Debounce API calls
this.debounceTimer = setTimeout(() => {
this.fetchSuggestions(query);
}, 300);
}
async fetchSuggestions(query) {
const suggestionsList = this.shadowRoot.getElementById("suggestionsList");
const dropdown = this.shadowRoot.getElementById("suggestionsDropdown");
// Show loading state
suggestionsList.innerHTML = '<div class="loading">Loading...</div>';
dropdown.classList.add("show");
try {
const response = await fetch(
`/api/user/suggestion?query=${encodeURIComponent(query)}`,
);
if (!response.ok) {
throw new Error("Network response was not ok");
}
const data = await response.json();
this.displaySuggestions(data.users);
} catch (error) {
console.error("Error fetching suggestions:", error);
suggestionsList.innerHTML = `
<div class="alert alert-danger m-2" role="alert">
Error loading suggestions. Please try again.
</div>
`;
}
}
displaySuggestions(users) {
const suggestionsList = this.shadowRoot.getElementById("suggestionsList");
const dropdown = this.shadowRoot.getElementById("suggestionsDropdown");
if (!users || users.length === 0) {
suggestionsList.innerHTML = `
<div class="loading">No users found</div>
`;
return;
}
suggestionsList.innerHTML = users
.map(
(user) => `
<div class="list-group-item list-group-item-action suggestion-item" data-user='${JSON.stringify(user)}'>
<div class="d-flex w-100 justify-content-between align-items-start">
<div class="flex-grow-1">
<div class="user-display-name">${this.escapeHtml(user.display_name)}</div>
<div class="user-username">@${this.escapeHtml(user.username)}</div>
</div>
<div class="text-end">
<span class="badge bg-secondary user-org">${this.escapeHtml(user.organization.name)}</span>
</div>
</div>
</div>
`,
)
.join("");
// Add click handlers to suggestion items
suggestionsList.querySelectorAll(".suggestion-item").forEach((item) => {
item.addEventListener("click", (e) => {
const userData = JSON.parse(e.currentTarget.getAttribute("data-user"));
this.selectUser(userData);
});
});
dropdown.classList.add("show");
}
selectUser(user) {
this.selectedUser = user;
const input = this.shadowRoot.getElementById("userInput");
input.value = user.displayName || user.display_name;
this.hideSuggestions();
// Dispatch custom event
this.dispatchEvent(
new CustomEvent("user-selected", {
detail: { user },
bubbles: true,
composed: true,
}),
);
}
hideSuggestions() {
const dropdown = this.shadowRoot.getElementById("suggestionsDropdown");
dropdown.classList.remove("show");
}
escapeHtml(text) {
const map = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
};
return text.replace(/[&<>"']/g, (m) => map[m]);
}
// Public method to get selected user
getSelectedUser() {
return this.selectedUser;
}
// Public method to clear selection
clear() {
this.selectedUser = null;
const input = this.shadowRoot.getElementById("userInput");
input.value = "";
this.hideSuggestions();
}
}
// Register the custom element
customElements.define("user-selector", UserSelector);